The owl of Minerva spreads its wings only with the falling of the dusk

Markdown与其支持的的LaTeX
Markdown
- 圆点突出:- +文字
- 序号列表:数字+.
- 链接:[文本](链接)
- 图片:
- 斜体:*文字*
- 加粗:**文字**
- 粗斜体:***文字***
- 分割线:*********或者----------
采用>+文字
几个#就代表几级标题
反引号
import numpy as np
利用三个反引号,后面加上代码使用的语言,如python可实现高亮
表格
| 表头 | 表头 |
| ---- | ---- |
| 单元格 | 单元格 |
| 单元格 | 单元格 |
| 表头 | 表头 |
|---|---|
| 单元格 | 单元格 |
| 单元格 | 单元格 |
还可设置表格的对齐方式
- :- 实现左对齐
- -: 实现右对齐
- :-: 实现居中
| 左对齐 | 右对齐 | 居中对齐 |
| :-----| ----: | :----: |
| 1 | 1 | 1 |
| 左对齐 | 右对齐 | 居中对齐 |
|---|---|---|
| 1 | 1 | 1 |
markdown中的LaTeX
支持的写法详情见katex官方文档
多行公式
为公式加编号:\tag{number}
在VSCODE中必须使用aligned环境,如:
$$
\begin{aligned}
a&=1\\
&=2
\end{aligned}
$$
分段函数
使用cases环境
$$
f(x) =
\begin{cases}
a, a<1,\\
b, a\geq1.
\end{cases}
$$
效果如下
文内链接
[能够点击的链接](#name)
<div id="name"></div>
python
numpy
import numpy as np
基础
- 数组:np.array([[1,2,3],[4,5,6],...])
- 序列:np.arange(a,b,c) 初值a,终值b,步长c,不含终值
- 序列:np.linspace(a,b,c) 初值a,终值b,个数c,含终值
- 空数组:np.empty((a,b),np.int) shape&type
- 零数组:np.zeros((a,b),np.int)
- 单位阵:np.eye(N,M=None,k=0) 行数N,列数M,k为对角线上移(正)或下移(负)
- 全1阵:np.ones((a,b),np.int)
- 转置与内积:np.dot(A.T,A)
切片与索引
切片是视图而非副本,若要副本:arr[5:8].copy()
布尔型索引:data[data<0]=0, ~可用来反转条件
通用函数func
一元函数:np. f (arr)
- abs,fabs,sqrt开根,square平方
- exp,log,log10,log2
- sign,ceil向上取整,floor向下取整,rint四舍五入,modf拆成整数和小数
- isnan,isfinite,isinf
- cos,sin,cosh,sinh,tan,tanh
二元函数:np. f (arr)
- add,substract,multiply,divide,floor_divide 除后取整
- power,maximum,fmax,mod
- copysign 得到第二个数组的符号
- greater,greater_equal,less,less_equal,equal,not_equal返回布尔值
- meshgrid 接受两个一维数组,产生两个二维数组,对应所有(x,y)对
np.where()是 x if condition else y的矢量化版本 np.where(arr>2,2,-2) np.where(arr>2,2,arr)
数组统计方法
- sum,mean,std,var,min,max
- argmax,argmin 索引,cumsum,cumprod
- 查询数组中是否有true:all,any(示例:
(a=b).all())
排序
sort
集合运算,数字1
- unique(x)
- intersect1d(x,y) 交集
- union1d(x,y) 并集
- in1d(x,y) 包含于
- setdiff1d(x,y) 差集
- setxor1d(x,y) 对称差
常用numpy.linalg函数(npl)
- diag 对角阵和一维数组转化
- dot,trace,det
- eig 特征值特征向量
- inv 逆
- pinv Moore-Penrose 伪逆
- qr QR分解
- svd 奇异值分解
- solve 解Ax=b ,A方针
- lstsq Ax=b最小二乘解
部分numpy.random函数
- seed 确定随机数生成器种子
- permutation 返回新的打乱的x,x不变
- shuffle 原地打乱x
- rand 均匀分布
- randint 给定范围内随机取整数
- randn 标准正态分布
- binomial 二项分布
- normal 正态分布
- beta,gamma
- chisquare 卡方分布
- uniform [0,1)均匀分布
数组合并与拼接
- append(arr,values,axis=None)
Pandas
Series([1, 2, 3, 4], index=[a, b, c, d]) 查缺失数据 .isnull(), .notnull(), 返回同结构的布尔值 Series对象本身及其索引有个 name 属性
a = pd.Series([1 ,2 ,3 ,4], index=['a', 'b', 'c', 'd'])
a.name = 'series'
将序列作为 DataFrame 的一列时,name属性就变为那一列的列名
DataFrame
data = np.ones([3,4])
d = pd.DataFrame(data, index=['a','b','c'], columns=['a','b','c','d'])
.head() 取前五行,.tail() .del() 删除某一列,.drop()删除指定轴上某些项 .append() .difference() .intersection() .union()
索引 用标签名 data.loc['a',['c','d']] 不用标签名 data.iloc[2,[2,3]]
常用方法 .cumsum() .cumprod() .diff() .pct_change()
换指定列名 d=d.rename( index={1:'new'}, columns={'a':'shit'} )
matplotlib
import matplotlib.pyplot as plt
data1 = np.linspace(1,200,2000)
data2 = np.random.randn(2000)
fig = plt.figure()
ax1 = fig.add_subplot(2,2,1)
plt.plot(data1, label='first')
plt.plot(data2,'.', label='second')
ax1.set_xticks([1,2,40])
ax1.legend(loc='best')
ax1.set_title('first plot')
ax1.set_xlabel('index')
ax2 = fig.add_subplot(2,2,2)
'-'实线 '--'短划线 '-.'点划线 ':'虚线 '.'点 'v'倒三角 等 color参数 'b'蓝 'g'绿 'r'红 'c'青 'm'品红 'y'黄 'k'黑 'w'白
python爬虫_BeautifulSoup
获取浏览器模拟头部信息
- 浏览器输入网址
- F12 网络(network)
- 随便点一个,最下方请求标头‘User-Agent’部分
- 复制到脚本中head = {‘User-Agent’:xxxx}
# 查看头部
python正则表达式
正则表达式基础
语法
-
普通字符:
-
-
普通字符包括没有显式指定为元字符的所有可打印和不可打印字符。这包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。
-
[ABC]:匹配 [...] 中的所有字符,例如 [aeiou] 匹配字符串 "google runoob taobao" 中所有的 e o u a 字母
-
1:匹配除了 [...] 中字符的所有字符,例如 2 匹配字符串 "google runoob taobao" 中除了 e o u a 字母的所有字母
-
[A-Z]:[A-Z] 表示一个区间,匹配所有大写字母,[a-z] 表示所有小写字母
-
. :匹配除换行符**(\n、\r)之外的任何单个字符,相等于[ ^\n\r]**
-
[\s\S]:匹配所有。\s 是匹配所有空白符,包括换行,\S 非空白符,不包括换行
-
\w:匹配字母、数字、下划线。等价于 [A-Za-z0-9_]
-
-
非打印字符:
-
-
非打印字符也可以是正则表达式的组成部分。下表列出了表示非打印字符的转义序列
-
\cx:匹配由x指明的控制字符。例如, \cM 匹配一个 Control-M 或回车符。x 的值必须为 A-Z 或 a-z 之一。否则,将 c 视为一个原义的 'c' 字符
-
\f:匹配一个换页符。等价于 \x0c 和 \cL
-
\n:匹配一个换行符。等价于 \x0a 和 \cJ
-
\r:匹配一个回车符。等价于 \x0d 和 \cM
-
\s:匹配任何空白字符,包括空格、制表符、换页符等等。等价于 [ \f\n\r\t\v]。注意 Unicode 正则表达式会匹配全角空格符
-
\S:匹配任何非空白字符。等价于 [ ^ \f\n\r\t\v]
-
\t:匹配一个制表符。等价于 \x09 和 \cI
-
\v:匹配一个垂直制表符。等价于 \x0b 和 \cK
-
-
特殊字符:
-
-
$:匹配输入字符串的结尾位置。要匹配 **** 字符本身,请使用 \$
-
( ):标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。要匹配这些字符,请使用 ( 和 )
-
*****:匹配前面的子表达式零次或多次。要匹配 * 字符,请使用 *
-
+:匹配前面的子表达式一次或多次。要匹配 + 字符,请使用 +
-
. :匹配除换行符 \n 之外的任何单字符。要匹配 . ,请使用 .
-
[:标记一个中括号表达式的开始。要匹配 [,请使用 [
-
?:匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。要匹配 ? 字符,请使用 ?
-
\:将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如, 'n' 匹配字符 'n'。'\n' 匹配换行符。序列 '\' 匹配 "",而 '(' 则匹配 "("
-
^:匹配输入字符串的开始位置,除非在方括号表达式中使用,当该符号在方括号表达式中使用时,表示不接受该方括号表达式中的字符集合。要匹配 ^ 字符本身,请使用 ^
-
{:标记限定符表达式的开始。要匹配 {,请使用 {
-
|:指明两项之间的一个选择。要匹配 |,请使用 |
-
-
限定符:
-
-
限定符用来指定正则表达式的一个给定组件必须要出现多少次才能满足匹配
-
:匹配前面的子表达式零次或多次。例如,zo* 能匹配 "z" 以及 "zoo"。**** 等价于 {0,}
-
+:匹配前面的子表达式一次或多次。例如,zo+ 能匹配 "zo" 以及 "zoo",但不能匹配 "z"。+ 等价于 {1,}
-
?:匹配前面的子表达式零次或一次。例如,do(es)? 可以匹配 "do" 、 "does"、 "doxy" 中的 "do" 。? 等价于 {0,1}
-
{n}:n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 "Bob" 中的 o,但是能匹配 "food" 中的两个 o
-
{n,}:n 是一个非负整数。至少匹配n 次。例如,o{2,} 不能匹配 "Bob" 中的 o,但能匹配 "foooood" 中的所有 o。o{1,} 等价于 o+。o{0,} 则等价于 o*
-
{n,m}:m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 "fooooood" 中的前三个 o。o{0,1} 等价于 o?。请注意在逗号和两个数之间不能有空格
-
-
定位符:
-
- 定位符能够将正则表达式固定到行首或行尾。它们还能够创建这样的正则表达式,这些正则表达式出现在一个单词内、在一个单词的开头或者一个单词的结尾。
- ^:匹配输入字符串开始的位置
- ****:匹配输入字符串结尾的位置
- \b:匹配一个单词边界,即字与空格间的位置
- \B:非单词边界匹配
- 注意:不能将限定符与定位符一起使用。由于在紧靠换行或者单词边界的前面或后面不能有一个以上位置,因此不允许诸如 ^* 之类的表达式
-
选择:
-
- 用圆括号 () 将所有选择项括起来,相邻的选择项之间用 | 分隔
修饰符/标记
-
标记也称为修饰符,正则表达式的标记用于指定额外的匹配策略。
标记不写在正则表达式里,标记位于表达式之外,格式如下
/pattern/flags -
i:ignore - 不区分大小写,
-
g:global - 全局匹配
-
m:multiline - 多行匹配
-
s:特殊字符圆点 . 中包含换行符 \n
元字符(重要)
下表包含了元字符的完整列表以及它们在正则表达式上下文中的行为
| 字符 | 描述 |
|---|---|
| \ | 将下一个字符标记为一个特殊字符、或一个原义字符、或一个 向后引用、或一个八进制转义符。例如,'n' 匹配字符 "n"。'\n' 匹配一个换行符。序列 '\' 匹配 "" 而 "(" 则匹配 "("。 |
| ^ | 匹配输入字符串的开始位置。如果设置了 RegExp 对象的 Multiline 属性,^ 也匹配 '\n' 或 '\r' 之后的位置。 |
| ** 也匹配 '\n' 或 '\r' 之前的位置。 | |
| ***** | 匹配前面的子表达式零次或多次。例如,zo* 能匹配 "z" 以及 "zoo"。* 等价于{0,}。 |
| + | 匹配前面的子表达式一次或多次。例如,'zo+' 能匹配 "zo" 以及 "zoo",但不能匹配 "z"。+ 等价于 {1,}。 |
| ? | 匹配前面的子表达式零次或一次。例如,"do(es)?" 可以匹配 "do" 或 "does" 。? 等价于 {0,1}。 |
| {n} | n 是一个非负整数。匹配确定的 n 次。例如,'o{2}' 不能匹配 "Bob" 中的 'o',但是能匹配 "food" 中的两个 o。 |
| {n,} | n 是一个非负整数。至少匹配n 次。例如,'o{2,}' 不能匹配 "Bob" 中的 'o',但能匹配 "foooood" 中的所有 o。'o{1,}' 等价于 'o+'。'o{0,}' 则等价于 'o*'。 |
| {n,m} | m 和 n 均为非负整数,其中n <= m。最少匹配 n 次且最多匹配 m 次。例如,"o{1,3}" 将匹配 "fooooood" 中的前三个 o。'o{0,1}' 等价于 'o?'。请注意在逗号和两个数之间不能有空格。 |
| ? | 当该字符紧跟在任何一个其他限制符 (*, +, ?, {n}, {n,}, {n,m}) 后面时,匹配模式是非贪婪的。非贪婪模式尽可能少的匹配所搜索的字符串,而默认的贪婪模式则尽可能多的匹配所搜索的字符串。例如,对于字符串 "oooo",'o+?' 将匹配单个 "o",而 'o+' 将匹配所有 'o'。 |
| . | 匹配除换行符(\n、\r)之外的任何单个字符。要匹配包括 '\n' 在内的任何字符,请使用像"(.|\n)"的模式。 |
| (pattern) | 匹配 pattern 并获取这一匹配。所获取的匹配可以从产生的 Matches 集合得到,在VBScript 中使用 SubMatches 集合,在JScript 中则使用 9 属性。要匹配圆括号字符,请使用 '(' 或 ')'。 |
| (?:pattern) | 匹配 pattern 但不获取匹配结果,也就是说这是一个非获取匹配,不进行存储供以后使用。这在使用 "或" 字符 (|) 来组合一个模式的各个部分是很有用。例如, 'industr(?:y|ies) 就是一个比 'industry|industries' 更简略的表达式。 |
| (?=pattern) | 正向肯定预查(look ahead positive assert),在任何匹配pattern的字符串开始处匹配查找字符串。这是一个非获取匹配,也就是说,该匹配不需要获取供以后使用。例如,"Windows(?=95|98|NT|2000)"能匹配"Windows2000"中的"Windows",但不能匹配"Windows3.1"中的"Windows"。预查不消耗字符,也就是说,在一个匹配发生后,在最后一次匹配之后立即开始下一次匹配的搜索,而不是从包含预查的字符之后开始。 |
| (?!pattern) | 正向否定预查(negative assert),在任何不匹配pattern的字符串开始处匹配查找字符串。这是一个非获取匹配,也就是说,该匹配不需要获取供以后使用。例如"Windows(?!95|98|NT|2000)"能匹配"Windows3.1"中的"Windows",但不能匹配"Windows2000"中的"Windows"。预查不消耗字符,也就是说,在一个匹配发生后,在最后一次匹配之后立即开始下一次匹配的搜索,而不是从包含预查的字符之后开始。 |
| (?<=pattern) | 反向(look behind)肯定预查,与正向肯定预查类似,只是方向相反。例如,"(?<=95|98|NT|2000)Windows"能匹配"2000Windows"中的"Windows",但不能匹配"3.1Windows"中的"Windows"。 |
| (?<!pattern) | 反向否定预查,与正向否定预查类似,只是方向相反。例如"(?<!95|98|NT|2000)Windows"能匹配"3.1Windows"中的"Windows",但不能匹配"2000Windows"中的"Windows"。 |
| x|y | 匹配 x 或 y。例如,'z|food' 能匹配 "z" 或 "food"。'(z|f)ood' 则匹配 "zood" 或 "food"。 |
| [xyz] | 字符集合。匹配所包含的任意一个字符。例如, '[abc]' 可以匹配 "plain" 中的 'a'。 |
| 3 | 负值字符集合。匹配未包含的任意字符。例如, '[ ^abc]' 可以匹配 "plain" 中的'p'、'l'、'i'、'n'。 |
| [a-z] | 字符范围。匹配指定范围内的任意字符。例如,'[a-z]' 可以匹配 'a' 到 'z' 范围内的任意小写字母字符。 |
| 4 | 负值字符范围。匹配任何不在指定范围内的任意字符。例如,'[ ^a-z]' 可以匹配任何不在 'a' 到 'z' 范围内的任意字符。 |
| \b | 匹配一个单词边界,也就是指单词和空格间的位置。例如, 'er\b' 可以匹配"never" 中的 'er',但不能匹配 "verb" 中的 'er'。 |
| \B | 匹配非单词边界。'er\B' 能匹配 "verb" 中的 'er',但不能匹配 "never" 中的 'er'。 |
| \cx | 匹配由 x 指明的控制字符。例如, \cM 匹配一个 Control-M 或回车符。x 的值必须为 A-Z 或 a-z 之一。否则,将 c 视为一个原义的 'c' 字符。 |
| \d | 匹配一个数字字符。等价于 [0-9]。 |
| \D | 匹配一个非数字字符。等价于 [ ^0-9]。 |
| \f | 匹配一个换页符。等价于 \x0c 和 \cL。 |
| \n | 匹配一个换行符。等价于 \x0a 和 \cJ。 |
| \r | 匹配一个回车符。等价于 \x0d 和 \cM。 |
| \s | 匹配任何空白字符,包括空格、制表符、换页符等等。等价于 [ \f\n\r\t\v]。 |
| \S | 匹配任何非空白字符。等价于 [ ^ \f\n\r\t\v]。 |
| \t | 匹配一个制表符。等价于 \x09 和 \cI。 |
| \v | 匹配一个垂直制表符。等价于 \x0b 和 \cK。 |
| \w | 匹配字母、数字、下划线。等价于'[A-Za-z0-9_]'。 |
| \W | 匹配非字母、数字、下划线。等价于 '[ ^A-Za-z0-9_]'。 |
| \xn | 匹配 n,其中 n 为十六进制转义值。十六进制转义值必须为确定的两个数字长。例如,'\x41' 匹配 "A"。'\x041' 则等价于 '\x04' & "1"。正则表达式中可以使用 ASCII 编码。 |
| \num | 匹配 num,其中 num 是一个正整数。对所获取的匹配的引用。例如,'(.)\1' 匹配两个连续的相同字符。 |
| \n | 标识一个八进制转义值或一个向后引用。如果 \n 之前至少 n 个获取的子表达式,则 n 为向后引用。否则,如果 n 为八进制数字 (0-7),则 n 为一个八进制转义值。 |
| \nm | 标识一个八进制转义值或一个向后引用。如果 \nm 之前至少有 nm 个获得子表达式,则 nm 为向后引用。如果 \nm 之前至少有 n 个获取,则 n 为一个后跟文字 m 的向后引用。如果前面的条件都不满足,若 n 和 m 均为八进制数字 (0-7),则 \nm 将匹配八进制转义值 nm。 |
| \nml | 如果 n 为八进制数字 (0-3),且 m 和 l 均为八进制数字 (0-7),则匹配八进制转义值 nml。 |
| \un | 匹配 n,其中 n 是一个用四个十六进制数字表示的 Unicode 字符。例如, \u00A9 匹配版权符号 (?)。 |

运算符优先级
下表从最高到最低说明了各种正则表达式运算符的优先级顺序
| 运算符 | 描述 |
|---|---|
| \ | 转义符 |
| (), (?:), (?=), [] | 圆括号和方括号 |
| *, +, ?, {n}, {n,}, {n,m} | 限定符 |
| ^, , \任何元字符、任何字符 | 定位点和序列(即:位置和顺序) |
| | | 替换,"或"操作字符具有高于替换运算符的优先级,使得"m|food"匹配"m"或"food"。若要匹配"mood"或"food",请使用括号创建子表达式,从而产生"(m|f)ood"。 |
匹配规则
基本模式匹配
一切从最基本的开始。模式,是正则表达式最基本的元素,它们是一组描述字符串特征的字符。模式可以很简单,由普通的字符串组成,也可以非常复杂,往往用特殊的字符表示一个范围内的字符、重复出现,或表示上下文。例如:
^once
这个模式包含一个特殊的字符 ^,表示该模式只匹配那些以 once 开头的字符串。例如该模式与字符串 "once upon a time" 匹配,与 "There once was a man from NewYork" 不匹配。正如如 ^ 符号表示开头一样,**** 符号用来匹配那些以给定模式结尾的字符串。
bucket$
这个模式与 "Who kept all of this cash in a bucket" 匹配,与 "buckets" 不匹配。字符 ^ 和 **
只匹配字符串 **"bucket"**。如果一个模式不包括 **^** 和 **$**,那么它与任何包含该模式的字符串匹配。例如模式:
once
与字符串
There once was a man from NewYork Who kept all of his cash in a bucket.
是匹配的。
在该模式中的字母 **(o-n-c-e)** 是字面的字符,也就是说,他们表示该字母本身,数字也是一样的。其他一些稍微复杂的字符,如标点符号和白字符(空格、制表符等),要用到转义序列。所有的转义序列都用反斜杠 **\\** 打头。制表符的转义序列是 **\t**。所以如果我们要检测一个字符串是否以制表符开头,可以用这个模式:
^\t
类似的,用 **\n** 表示**"新行"**,**\r** 表示回车。其他的特殊符号,可以用在前面加上反斜杠,如反斜杠本身用 **\\\\** 表示,句号 **.** 用 **\\.** 表示,以此类推。
#### 字符簇
在 INTERNET 的程序中,正则表达式通常用来验证用户的输入。当用户提交一个 FORM 以后,要判断输入的电话号码、地址、EMAIL 地址、信用卡号码等是否有效,用普通的基于字面的字符是不够的。
所以要用一种更自由的描述我们要的模式的办法,它就是字符簇。要建立一个表示所有元音字符的字符簇,就把所有的元音字符放在一个方括号里:
[AaEeIiOoUu]
这个模式与任何元音字符匹配,但只能表示一个字符。用连字号可以表示一个字符的范围,如:
[a-z] // 匹配所有的小写字母 [A-Z] // 匹配所有的大写字母 [a-zA-Z] // 匹配所有的字母 [0-9] // 匹配所有的数字 [0-9.-] // 匹配所有的数字,句号和减号 [ \f\r\t\n] // 匹配所有的白字符
同样的,这些也只表示一个字符,这是一个非常重要的。如果要匹配一个由一个小写字母和一位数字组成的字符串,比如 "z2"、"t6" 或 "g7",但不是 "ab2"、"r2d3" 或 "b52" 的话,用这个模式:
^[a-z][0-9]
尽管 **[a-z]** 代表 26 个字母的范围,但在这里它只能与第一个字符是小写字母的字符串匹配。
前面曾经提到^表示字符串的开头,但它还有另外一个含义。当在一组方括号里使用 **^** 时,它表示"**非**"或"**排除**"的意思,常常用来剔除某个字符。还用前面的例子,我们要求第一个字符不能是数字:
^[^0-9][0-9]
这个模式与 "&5"、"g7"及"-2" 是匹配的,但与 "12"、"66" 是不匹配的。下面是几个排除特定字符的例子:
4 //除了小写字母以外的所有字符 5 //除了()(/)(^)之外的所有字符 6 //除了双引号(")和单引号(')之外的所有字符
特殊字符 **.**(点,句号)在正则表达式中用来表示除了"新行"之外的所有字符。所以模式 **^.5$** 与任何两个字符的、以数字5结尾和以其他非"新行"字符开头的字符串匹配。模式 **.** 可以匹配任何字符串,**换行符(\n、\r)除外**。
PHP的正则表达式有一些内置的通用字符簇,列表如下:
| 字符簇 | 描述 |
| :----------- | :---------------------------------- |
| [[:alpha:]] | 任何字母 |
| [[:digit:]] | 任何数字 |
| [[:alnum:]] | 任何字母和数字 |
| [[:space:]] | 任何空白字符 |
| [[:upper:]] | 任何大写字母 |
| [[:lower:]] | 任何小写字母 |
| [[:punct:]] | 任何标点符号 |
| [[:xdigit:]] | 任何16进制的数字,相当于[0-9a-fA-F] |
#### 确定重复出现
到现在为止,你已经知道如何去匹配一个字母或数字,但更多的情况下,可能要匹配一个单词或一组数字。一个单词有若干个字母组成,一组数字有若干个单数组成。跟在字符或字符簇后面的花括号({})用来确定前面的内容的重复出现的次数。
| 字符簇 | 描述 |
| :--------------- | :------------------------------ |
| ^[a-zA-Z_]$ | 所有的字母和下划线 |
| ^[[:alpha:]]{3}$ | 所有的3个字母的单词 |
| ^a$ | 字母a |
| ^a{4}$ | aaaa |
| ^a{2,4}$ | aa,aaa或aaaa |
| ^a{1,3}$ | a,aa或aaa |
| ^a{2,}$ | 包含多于两个a的字符串 |
| ^a{2,} | 如:aardvark和aaab,但apple不行 |
| a{2,} | 如:baad和aaa,但Nantucket不行 |
| \t{2} | 两个制表符 |
| .{2} | 所有的两个字符 |
这些例子描述了花括号的三种不同的用法。一个数字 **{x}** 的意思是**前面的字符或字符簇只出现x次** ;一个数字加逗号 **{x,}** 的意思是**前面的内容出现x或更多的次数** ;两个数字用逗号分隔的数字 **{x,y}** 表示 **前面的内容至少出现x次,但不超过y次**。我们可以把模式扩展到更多的单词或数字:
^[a-zA-Z0-9_]{1,} // 所有的正整数 ^-{0,1}[0-9]{1,} // 所有的浮点数
最后一个例子不太好理解,是吗?这么看吧:以一个可选的负号 (**[-]?**) 开头 (**^**)、跟着1个或更多的数字(**[0-9]+**)、和一个小数点(**\.**)再跟上1个或多个数字**([0-9]+**),并且后面没有其他任何东西(**$**)。下面你将知道能够使用的更为简单的方法。
特殊字符 **?** 与 **{0,1}** 是相等的,它们都代表着: **0个或1个前面的内容** 或 **前面的内容是可选的** 。所以刚才的例子可以简化为:
^-?[0-9]{1,}.?[0-9]{1,} // 所有包含一个以上的字母、数字或下划线的字符串 ^[1-9][0-9]* // 所有的正整数 ^-?[0-9]+ // 所有的整数 ^[-]?[0-9]+(.[0-9]+)? // 所有的浮点数
当然这并不能从技术上降低正则表达式的复杂性,但可以使它们更容易阅读
## python re模块
```python
import re
re.match函数
re.match 尝试从字符串的起始位置匹配一个模式,如果不是起始位置匹配成功的话,match() 就返回 none
re.match(pattern, string, flags=0)
函数参数说明:
| 参数 | 描述 |
|---|---|
| pattern | 匹配的正则表达式 |
| string | 要匹配的字符串。 |
| flags | 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等等。参见:[正则表达式基础修饰符] |
匹配成功 re.match 方法返回一个匹配的对象,否则返回 None。
我们可以使用 group(num) 或 groups() 匹配对象函数来获取匹配表达式。
| 匹配对象方法 | 描述 |
|---|---|
| group(num=0) | 匹配的整个表达式的字符串,group() 可以一次输入多个组号,在这种情况下它将返回一个包含那些组所对应值的元组。 |
| groups() | 返回一个包含所有小组字符串的元组,从 1 到 所含的小组号。 |
import re
### match 实例
print(re.match('www', 'www.runoob.com').span()) # 在起始位置匹配
print(re.match('com', 'www.runoob.com')) # 不在起始位置匹配
# 输出为
# (0, 3)
# None
### group 实例
line = "Cats are smarter than dogs"
matchObj = re.match( r'(.*) are (.*?) .*', line, re.M|re.I)
if matchObj:
print "matchObj.group() : ", matchObj.group()
print "matchObj.group(1) : ", matchObj.group(1)
print "matchObj.group(2) : ", matchObj.group(2)
else:
print "No match!!"
# 输出为
# matchObj.group() : Cats are smarter than dogs
# matchObj.group(1) : Cats
# matchObj.group(2) : smarter
re.research 函数
re.search 扫描整个字符串并返回第一个成功的匹配
函数语法:
re.search(pattern, string, flags=0)
函数参数说明:
| 参数 | 描述 |
|---|---|
| pattern | 匹配的正则表达式 |
| string | 要匹配的字符串。 |
| flags | 标志位,用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等等。 |
匹配成功re.search方法返回一个匹配的对象,否则返回None。
我们可以使用group(num) 或 groups() 匹配对象函数来获取匹配表达式。
| 匹配对象方法 | 描述 |
|---|---|
| group(num=0) | 匹配的整个表达式的字符串,group() 可以一次输入多个组号,在这种情况下它将返回一个包含那些组所对应值的元组。 |
| groups() | 返回一个包含所有小组字符串的元组,从 1 到 所含的小组号。 |
import re
# search实例
print(re.search('www', 'www.runoob.com').span()) # 在起始位置匹配
print(re.search('com', 'www.runoob.com').span()) # 不在起始位置匹配
# 输出为
# (0, 3)
# (11, 14)
# group实例
line = "Cats are smarter than dogs";
searchObj = re.search( r'(.*) are (.*?) .*', line, re.M|re.I)
if searchObj:
print "searchObj.group() : ", searchObj.group()
print "searchObj.group(1) : ", searchObj.group(1)
print "searchObj.group(2) : ", searchObj.group(2)
else:
print "Nothing found!!"
# 输出为
# searchObj.group() : Cats are smarter than dogs
# searchObj.group(1) : Cats
# searchObj.group(2) : smarter
re.match与re.search的区别
re.match只匹配字符串的开始,如果字符串开始不符合正则表达式,则匹配失败,函数返回None;而re.search匹配整个字符串,直到找到一个匹配
re.sub 检索和替换
语法:
re.sub(pattern, repl, string, count=0, flags=0)
参数:
- pattern : 正则中的模式字符串。
- repl : 替换的字符串,也可为一个函数。
- string : 要被查找替换的原始字符串。
- count : 模式匹配后替换的最大次数,默认 0 表示替换所有的匹配。
import re
phone = "2004-959-559 # 这是一个国外电话号码"
# 删除字符串中的 Python注释
num = re.sub(r'#.*', "", phone)
print "电话号码是: ", num
# 删除非数字(-)的字符串
num = re.sub(r'\D', "", phone)
print "电话号码是 : ", num
# 输出为
# 电话号码是: 2004-959-559
# 电话号码是 : 2004959559
repl 参数可以是一个函数
import re
# 将匹配的数字乘以 2
def double(matched):
value = int(matched.group('value'))
return str(value * 2)
s = 'A23G4HFD567'
print(re.sub('(?P<value>\d+)', double, s))
# 输出为
# A46G8HFD1134
re.compile
compile 函数用于编译正则表达式,生成一个正则表达式( Pattern )对象,供 match() 和 search() 这两个函数使用,语法格式为
re.compile(pattern[, flags])
参数:
- pattern : 一个字符串形式的正则表达式
- flags : 可选,表示匹配模式,比如忽略大小写,多行模式等,具体参数为:
- re.I 忽略大小写
- re.L 表示特殊字符集 \w, \W, \b, \B, \s, \S 依赖于当前环境
- re.M 多行模式
- re.S 即为 . 并且包括换行符在内的任意字符(. 不包括换行符)
- re.U 表示特殊字符集 \w, \W, \b, \B, \d, \D, \s, \S 依赖于 Unicode 字符属性数据库
- re.X 为了增加可读性,忽略空格和 # 后面的注释
>>>import re
>>> pattern = re.compile(r'\d+') # 用于匹配至少一个数字
>>> m = pattern.match('one12twothree34four') # 查找头部,没有匹配
>>> print m
None
>>> m = pattern.match('one12twothree34four', 2, 10) # 从'e'的位置开始匹配,没有匹配
>>> print m
None
>>> m = pattern.match('one12twothree34four', 3, 10) # 从'1'的位置开始匹配,正好匹配
>>> print m # 返回一个 Match 对象
<_sre.SRE_Match object at 0x10a42aac0>
>>> m.group(0) # 可省略 0
'12'
>>> m.start(0) # 可省略 0
3
>>> m.end(0) # 可省略 0
5
>>> m.span(0) # 可省略 0
(3, 5)
findall
在字符串中找到正则表达式所匹配的所有子串,并返回一个列表,如果有多个匹配模式,则返回元组列表,如果没有找到匹配的,则返回空列表
注意: match 和 search 是匹配一次 findall 匹配所有
参数:
- string : 待匹配的字符串
- pos : 可选参数,指定字符串的起始位置,默认为 0
- endpos : 可选参数,指定字符串的结束位置,默认为字符串的长度
import re
pattern = re.compile(r'\d+') # 查找数字
result1 = pattern.findall('runoob 123 google 456')
result2 = pattern.findall('run88oob123google456', 0, 10)
print(result1)
print(result2)
# 输出
# ['123', '456']
# ['88', '12']
pytorch
创建网络的一种快捷方法:Sequential
net = torch.nn.Sequential(
torch.nn.Linear(STATE_SIZE, HIDDEN_SIZE),
torch.nn.ReLU(),
torch.nn.Linear(HIDDEN_SIZE, ACTION_SIZE),
)
2.1 构造张量的函数
torch.tensor() torch.zeros(), torch.zeros_like() torch.ones(), torch.ones_like() torch.full(), torch.full_like() 全填充为指定值 torch.empty(), torch.empty_like() torch.eye() torch.arange(), torch.range(), torch.linspace() torch.logspace() 等比 torch.rand(), torch.rand_like() 标准均匀 torch.randn(), torch.randn_like(), torch.normal() 标准正态 torch.randint(), torch.randint_like() torch.bernoulli() 两点分布 torch.multinomial() torch.randperm() {0,1,2,3...,n-1}的随机排列
2.2 重排张量元素
以下三种不会改变张量的实际位置(浅拷贝)
- reshape()
- squeeze():消除张量中大小为 的维度,
t.squeeze() - unsqueeze():添加一个大小为 的维度,
t.unsqueeze(dim=2)
2.3 张量扩展和拼接
- repeat()
- cat():两个参数,第一个是要拼接的张量的列表,第二个是延哪一个维度
- stack():同上,不同在于 stack 要求拼接的张量大小完全一样,延一个新的维度拼接
2.4 求解优化问题
- 在构造用做自变量的 torch.Tensor 类实例时,应将参数 requires_grad 设置为 True
- 调用张量类实例的成员方法 backward() 可以求偏导,调用完后,自变量的属性 grad 就储存了偏导的数值
from math import pi
import torch
x = torch.tensor([ pi/3 , pi/6 ], requires_grad=True)
f = -((x.cos()**2).sum)**2
print(f'value = {f}')
f.backward()
print(f'grad = {x.grad}')
优化算法与torch.optim包
在梯度下降时,先调用优化器实例的方法 zero_grad() 清空优化器在上次迭代中储存的数据,然后调用 torch.tensor 类实例的方法 backward() 求梯度,最后使用优化器的方法 step() 更新自变量的值
optimizer.zero_grad()
f.backward()
optimizer.step()
使用 torch.optim.SGD 梯度下降的一个实例
from math import pi
import torch
import torch.optim
x = torch.tensor([ pi/3 , pi/6 ], requires_grad=True)
optimizer = torch.optim.SGD([x,], lr=0.1 ,momentum=0)
for step in range(11):
if step:
optimizer.zero_grad()
f.backward()
optimizer.step()
f = -((x.cos()**2).sum)**2
print(f'step {step}: x = {x.tolist()}, f(x) = {f}')
torch.nn子包与损失类
torch.nn.Module 类及其子类可有以下用途
- 表示一个神经网络.如:torch.nn.Sequential 类可以表示一个前馈神经网络
- 表示神经网络的一个层:如 torch.nn.Linear 线性层,torch.nn.ReLU 激活层
- 表示损失:torch.nn.MSELoss,torh.nn.CrossEntropyLoss 等
激活层中逐元素激活分为以下三类
- S 型激活:Sigmoid,Softsign,Tanh,Hardtanh,ReLU6
- 单侧激活:ReLU,LeakyReLU,PReLU,RReLU,Threshold,ELU,SELU,Softplus,LogSigmoid
- 褶皱激活:Hardshrinkage,Softshrinkage,Tanhshrinkage
非逐元素激活
- Softmax,Softmax2d,LogSoftmax
torch.nn 里的损失类都是 torch.nn.Module 类的子类
criterion = torch.nn.MSELoss()
pred = torch.arange(5, requires_grad=True)
y = torch.ones(5)
loss = criterion(pred, y)
loss.backward()
训练集、验证集与训练集
训练集用来计算参数,验证集来判定欠拟合或过拟合,测试机来评价最终结果

| 欠拟合 | 过拟合 | |
|---|---|---|
| 泛化差错主要来源 | 偏差差错 (bias) | 方差差错 (variance) |
| 模型复杂度 | 过低 | 过高 |
| 学习曲线和验证曲线特征 | 收敛到比较大的差错值 | 两条曲线之间差别大 |
| 解决方案 | 增加模型复杂度 | 减小模型复杂度或增大训练集 |
2.5 标准化
- 批标准化( batch normalization ):对同一通道使用相同的均值和方差进行归一化,更适用于特征提取这样的应用
- 实例标准化( instance normalization ):对同一通道使用不同的均值和方差进行归一化,更适用于生成数据这样的应用
| 标准化操作类型 | 维度 | 标准化类 | 输入输出张量维度 | 适用网络 |
|---|---|---|---|---|
| 批标准化 | 1 | torch.nn.BatchNorm1d | 前馈神经网络 | |
| 批标准化 | 2 | torch.nn.BatchNorm2d | 前馈神经网络 | |
| 批标准化 | 3 | torch.nn.BatchNorm3d | 前馈神经网络 | |
| 实例标准化 | 1 | torch.nn.InstanceNorm1d | 前馈神经网络 | |
| 实例标准化 | 2 | torch.nn.InstanceNorm2d | 前馈神经网络 | |
| 实例标准化 | 3 | torch.nn.InstanceNorm3d | 前馈神经网络 | |
| 层标准化 | 不限 | torch.nn.LayerNorm | 前馈神经网络 |
2.6 网络权重初始化
pytorch 中完成权重初始化需要 torch.nn.init 子包和 torch.nn.Module 类成员方法 apply().
| 函数名 | 元素分布 | 分布参数确定方法 |
|---|---|---|
| torch.nn.init.uniform_() | 均匀分布 | 传入表示最小值的参数 a (默认为 0 )和表示最大值的参数 b (默认为 1 ) |
| torch.nn.init.normal_() | 正态分布 | 传入表示均值的参数 mean (默认为 0 )和表示方差的参数 std (默认为 1 ) |
| torch.nn.init.constant_() | 常量 | 传入常量 vaL |
| torch.nn.init.xavier_uniform_() | 均匀分布 | 均值为 0 ,标准差 根据输入的张量大小和增益函数 gain 计算得到 |
| torch.nn.init.xavier_uniform_() | 均匀分布 | 均值为 0 ,标准差 根据输入的张量大小和增益函数 gain 计算得到 |
apply() 方法有一个参数,参数是一个 python 函数,这个函数的参数必须是 torch.nn.Module 类.
import torch.nn.init as init
def weights_init(m):
init.xavier_normal_(m.weight)
init.constant_(m.bias, 0)
2.7 卷积神经网络
对一维卷积,设 为输入张量,大小为 , 为卷积核,大小为 ,输出张量为 ,大小为 ,则有
补全 (pad) 运算

在补零后 (前后各补 ,) ,相应的张量维度为
核的膨胀(dilate),基本互相关中,每个权重连续对应着输入张量中的元素,此时可认为膨胀系数为 ,膨胀前后核大小关系为 图 8-4 给出了膨胀系数 的例子.膨胀前,核的大小为 ,膨胀后,

步幅(stride),基本互相关中,卷积核每次相对输入张量 向右移动一个元素的位置并得到一个输出张量,一共 个输出.将此输出大小记为 视为可以认为基本互相关操作的步幅 ,如果考虑更大步幅,则有
补全、步幅、膨胀可以综合使用.综合前文,输入大小 ,输出大小 ,核张量大小 ,两侧分别补全数 和 ,步幅 ,膨胀系数 之间的关系满足 将以上几式综合起来,可以得到
torch.nn 里的卷积层
| 运算类型 | 运算维度 | torch.nn.Module子类 | 类实例输入张量的大小 | 类实例输出张量的大小 |
|---|---|---|---|---|
| 互相关 | 1 | torch.nn.Conv1d | ||
| 互相关 | 2 | torch.nn.Conv2d | ||
| 互相关 | 3 | torch.nn.Conv3d |
为样本的计数, 表示数据的通道数,即一条数据有几个 维张量.卷积层的输出通道数表示最多支持的特征个数.因为每个通道使用相同的卷积核计算,每个卷积核只能提取一种特征.
conv = torch.nn.Conv2d(16, 33, kernel_size={3, 5}, stride={2, 1}, padding={4, 2}, dilation={3, 1})
inputs = torch.rand(20, 16, 50, 100) #20条样本,16个通道,每个通道大小为 50*100
outputs = conv(inputs)
outputs.size()
张量的池化
池化 (pooling),核不需要权重
- 最大池化(max pool):输出张量的每个元素都是若干个输入张量的最大值
- 平均池化(average pool):输出元素由若干个输入元素求平均得到
- 池化( pool):计算输入元素组合的 范数


以下为不带“自适应”(adaptive)的版本,带自适应只需在 MaxPool1d 前加上 Adaptive,此时不能设置补全数等,他会自动帮你计算
| 运算类型 | 运算维度 | torch.nn.Module子类 | 类实例输入张量的大小 | 类实例输出张量的大小 |
|---|---|---|---|---|
| 最大池化 | 1 | torch.nn.MaxPool1d | ||
| 最大池化 | 2 | torch.nn.MaxPool2d | ||
| 最大池化 | 3 | torch.nn.MaxPool3d | ||
| 平均池化 | 1 | torch.nn.AvgPool1d | ||
| 平均池化 | 2 | torch.nn.AvgPool2d | ||
| 平均池化 | 3 | torch.nn.AvgPool3d | ||
| 池化 | 1 | torch.nn.LPPool1d | ||
| 池化 | 2 | torch.nn.LPPool2d | ||
| 最大反池化 | 1 | torch.nn.MaxUnpool1d | ||
| 最大反池化 | 2 | torch.nn.MaxUnpool2d | ||
| 最大反池化 | 3 | torch.nn.MaxUnpool3d |
张量的上采样
张量的上采样(up-sample),将输入张量的每个维度大小扩展若干倍.
- 最邻近上采样( nearest up-sample ):按照一个比例因子( scale factor )将每个元素重复若干次
- 线性插值上采样( linearup-sample )
pytorch 中上采样用的是 torch.nn 的子包 torch.nn.Unsample 类.
| 运算类型 | 运算维度 | torch.nn.Unsample类实例构造参数 | 类实例输入张量的大小 | 类实例输出张量的大小 |
|---|---|---|---|---|
| 最邻近上采样 | 1 | mode='nearest'(默认值) | ||
| 最邻近上采样 | 2 | mode='nearest'(默认值) | ||
| 最邻近上采样 | 3 | mode='nearest'(默认值) | ||
| 线性上采样 | 1 | mode='linear' | ||
| 线性上采样 | 2 | mode='bilinear' | ||
| 线性上采样 | 3 | mode='trilinear' |
张量的补全运算
- 常数补全( constant pad ):输入张量前后补上常数
- 重复补全( replication pad ):用最边上的值补全
- 反射补全( reflection pad ):以边界为对称轴补全
| 运算类型 | 运算维度 | torch.nn.Module子类 | 类实例输入张量的大小 | 类实例输出张量的大小 |
|---|---|---|---|---|
| 常数补全 | 2 | torch.nn.ConstantPad2d | ||
| 重复补全 | 2 | torch.nn.ReplicationPad2d | ||
| 反射补全 | 2 | torch.nn.ReflectionPad2d | ||
| 反射补全 | 3 | torch.nn.Reflection3d |
inputs = torch.arange(12).view(1, 1, 3, 4)
pad = nn.ConstantPad2d(padding=[1, 1, 1, 1], value=-1)
pad = nn.Replication2d(padding=[1, 1, 1, 1])
pad = nn.Reflection2d(padding=[1, 1, 1, 1])
例如实现下图的卷积网络,可以参考的构建网络方法:

import torch.nn
class Net(torch.nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv0 = torch.nn.Conv2d(1, 64, kernel_size=3, padding=1)
self.relu1 = torch.nn.ReLU()
self.conv2 = torch.nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.relu3 = torch.nn.ReLU()
self.pool4 = torch.nn.MaxPool2d(stride=2, kernel_size=2)
self.fc5 = torch.nn.Linear(128*14*14, 1024)
self.relu6 = torch.nn.ReLU()
self.drop7 = torch.nn.Dropout(p=0.5)
self.fc8 = torch.nn.Linear(1024, 10)
def forward(self, x):
x = self.conv0(x)
x = self.relu1(x)
x = self.conv2(x)
x = self.relu3(x)
x = self.pool4(x)
x = x.view(-1, 128 * 14 * 14)
x = self.fc5(x)
x = self.relu6(x)
x = self.drop7(x)
x = self.fc8(x)
return x
net = Net()
另外可用 sequential 方法
import torch.nn
class Net(torch.nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv = torch.nn.Sequential(
torch.nn.Conv2d(1, 64, kernel_size=3, padding=1)
torch.nn.ReLU()
torch.nn.Conv2d(64, 128, kernel_size=3, padding=1)
torch.nn.ReLU()
torch.nn.MaxPool2d(stride=2, kernel_size=2))
self.dense = torch.nn.Sequential(
torch.nn.Linear(128*14*14, 1024)
torch.nn.ReLU()
torch.nn.Dropout(p=0.5)
torch.nn.Linear(1024, 10))
def forward(self, x):
x = self.conv(x)
x = x.view(-1, 128 * 14 * 14)
x = self.dense(x)
return x
net = Net()
2.8 循环神经网络
TODO:循环神经网络
以下是 LSTM 示例
import torch.nn
class Net(torch.nn.Module):
def __init__(self, input_size, hidden_size):
super(Net, self).__init__()
self.rnn = torch.nn.LSTM(input_size, hidden_size)
self.fc = torch.nn.Linear(hidden_size, 1)
def forward(self, x):
x = x[:, :, None]
x, _ = self.rnn(x)
x = self.fc(x)
x = x[:, :, 0]
return x
net = Net(input_size=1, hidden_size=5)
2.9 生成对抗网络
- 生成网络( generative network ):一般一条随机输入是一个有多个元素的张量 ,张量 的取值空间称为“潜在空间”( latent space ),张量 的元素个数称为“潜在大小”( latent size ).生成网络 可以将这条潜在张量样本 映射为一条数据张量 .
- 鉴别网络( discriminative network ):对生成网络生成的数据进行判定.
以 记交叉熵损失函数
目的:训练鉴别网络 使得 训练生成网络使得
以下是CIFAR-10图像生成的实例
'''读取数据'''
from torch.utils.data import DataLoader
from torchvision.datasets import CIFAR10
import torchvision.transforms as transforms
from torchvision.utils import save_image
dataset = CIFAR10(root='./data', download=True,
transform=transforms.ToTensor())
dataloader = DataLoader(dataset, batch_size=64, shuffle=True)
for batch_idx, data in enumerate(dataloader):
real_images, _ = data
batch_size = real_images.size(0)
print ('#{} has {} images.'.format(batch_idx, batch_size))
if batch_idx % 100 == 0:
path = './data/CIFAR10_shuffled_batch{:03d}.png'.format(batch_idx)
save_image(real_images, path, normalize=True)
'''生成网络与鉴别网络的搭建'''
import torch.nn as nn
# 搭建生成网络
latent_size = 64 # 潜在大小
n_channel = 3 # 输出通道数
n_g_feature = 64 # 生成网络隐藏层大小
gnet = nn.Sequential(
# 输入大小 = (64, 1, 1)
nn.ConvTranspose2d(latent_size, 4 * n_g_feature, kernel_size=4,
bias=False),
nn.BatchNorm2d(4 * n_g_feature),
nn.ReLU(),
# 大小 = (256, 4, 4)
nn.ConvTranspose2d(4 * n_g_feature, 2 * n_g_feature, kernel_size=4,
stride=2, padding=1, bias=False),
nn.BatchNorm2d(2 * n_g_feature),
nn.ReLU(),
# 大小 = (128, 8, 8)
nn.ConvTranspose2d(2 * n_g_feature, n_g_feature, kernel_size=4,
stride=2, padding=1, bias=False),
nn.BatchNorm2d(n_g_feature),
nn.ReLU(),
# 大小 = (64, 16, 16)
nn.ConvTranspose2d(n_g_feature, n_channel, kernel_size=4,
stride=2, padding=1),
nn.Sigmoid(),
# 图片大小 = (3, 32, 32)
)
print (gnet)
# 搭建鉴别网络
n_d_feature = 64 # 鉴别网络隐藏层大小
dnet = nn.Sequential(
# 图片大小 = (3, 32, 32)
nn.Conv2d(n_channel, n_d_feature, kernel_size=4,
stride=2, padding=1),
nn.LeakyReLU(0.2),
# 大小 = (64, 16, 16)
nn.Conv2d(n_d_feature, 2 * n_d_feature, kernel_size=4,
stride=2, padding=1, bias=False),
nn.BatchNorm2d(2 * n_d_feature),
nn.LeakyReLU(0.2),
# 大小 = (128, 8, 8)
nn.Conv2d(2 * n_d_feature, 4 * n_d_feature, kernel_size=4,
stride=2, padding=1, bias=False),
nn.BatchNorm2d(4 * n_d_feature),
nn.LeakyReLU(0.2),
# 大小 = (256, 4, 4)
nn.Conv2d(4 * n_d_feature, 1, kernel_size=4),
# 对数赔率张量大小 = (1, 1, 1)
)
print(dnet)
'''网络初始化'''
import torch.nn.init as init
def weights_init(m): # 用于初始化权重值的函数
if type(m) in [nn.ConvTranspose2d, nn.Conv2d]:
init.xavier_normal_(m.weight)
elif type(m) == nn.BatchNorm2d:
init.normal_(m.weight, 1.0, 0.02)
init.constant_(m.bias, 0)
gnet.apply(weights_init)
dnet.apply(weights_init)
'''训练并输出图片'''
import torch
import torch.optim
# 损失
criterion = nn.BCEWithLogitsLoss()
# 优化器
goptimizer = torch.optim.Adam(gnet.parameters(),
lr=0.0002, betas=(0.5, 0.999))
doptimizer = torch.optim.Adam(dnet.parameters(),
lr=0.0002, betas=(0.5, 0.999))
# 用于测试的固定噪声,用来查看相同的潜在张量在训练过程中生成图片的变换
batch_size = 64
fixed_noises = torch.randn(batch_size, latent_size, 1, 1)
# 训练过程
epoch_num = 10
for epoch in range(epoch_num):
for batch_idx, data in enumerate(dataloader):
# 载入本批次数据
real_images, _ = data
batch_size = real_images.size(0)
# 训练鉴别网络
labels = torch.ones(batch_size) # 真实数据对应标签为1
preds = dnet(real_images) # 对真实数据进行判别
outputs = preds.reshape(-1)
dloss_real = criterion(outputs, labels) # 真实数据的鉴别器损失
dmean_real = outputs.sigmoid().mean()
# 计算鉴别器将多少比例的真数据判定为真,仅用于输出显示
noises = torch.randn(batch_size, latent_size, 1, 1) # 潜在噪声
fake_images = gnet(noises) # 生成假数据
labels = torch.zeros(batch_size) # 假数据对应标签为0
fake = fake_images.detach()
# 使得梯度的计算不回溯到生成网络,可用于加快训练速度.删去此步结果不变
preds = dnet(fake) # 对假数据进行鉴别
outputs = preds.view(-1)
dloss_fake = criterion(outputs, labels) # 假数据的鉴别器损失
dmean_fake = outputs.sigmoid().mean()
# 计算鉴别器将多少比例的假数据判定为真,仅用于输出显示
dloss = dloss_real + dloss_fake # 总的鉴别器损失
dnet.zero_grad()
dloss.backward()
doptimizer.step()
# 训练生成网络
labels = torch.ones(batch_size)
# 生成网络希望所有生成的数据都被认为是真数据
preds = dnet(fake_images) # 把假数据通过鉴别网络
outputs = preds.view(-1)
gloss = criterion(outputs, labels) # 真数据看到的损失
gmean_fake = outputs.sigmoid().mean()
# 计算鉴别器将多少比例的假数据判定为真,仅用于输出显示
gnet.zero_grad()
gloss.backward()
goptimizer.step()
# 输出本步训练结果
print('[{}/{}]'.format(epoch, epoch_num) +
'[{}/{}]'.format(batch_idx, len(dataloader)) +
'鉴别网络损失:{:g} 生成网络损失:{:g}'.format(dloss, gloss) +
'真数据判真比例:{:g} 假数据判真比例:{:g}/{:g}'.format(
dmean_real, dmean_fake, gmean_fake))
if batch_idx % 100 == 0:
fake = gnet(fixed_noises) # 由固定潜在张量生成假数据
save_image(fake, # 保存假数据
'./data/images_epoch{:02d}_batch{:03d}.png'.format(
epoch, batch_idx))
从代码结构方面优化加速python
0. 代码优化原则
本文会介绍不少的 Python 代码加速运行的技巧。在深入代码优化细节之前,需要了解一些代码优化基本原则。
第一个基本原则是不要过早优化。很多人一开始写代码就奔着性能优化的目标,“让正确的程序更快要比让快速的程序正确容易得多”。因此,优化的前提是代码能正常工作。过早地进行优化可能会忽视对总体性能指标的把握,在得到全局结果前不要主次颠倒。
第二个基本原则是权衡优化的代价。优化是有代价的,想解决所有性能的问题是几乎不可能的。通常面临的选择是时间换空间或空间换时间。另外,开发代价也需要考虑。
第三个原则是不要优化那些无关紧要的部分。如果对代码的每一部分都去优化,这些修改会使代码难以阅读和理解。如果你的代码运行速度很慢,首先要找到代码运行慢的位置,通常是内部循环,专注于运行慢的地方进行优化。在其他地方,一点时间上的损失没有什么影响。
1. 避免全局变量
# 不推荐写法。代码耗时:26.8秒
import math
size = 10000
for x in range(size):
for y in range(size):
z = math.sqrt(x) + math.sqrt(y)
许多程序员刚开始会用 Python 语言写一些简单的脚本,当编写脚本时,通常习惯了直接将其写为全局变量,例如上面的代码。但是,由于全局变量和局部变量实现方式不同,定义在全局范围内的代码运行速度会比定义在函数中的慢不少。通过将脚本语句放入到函数中,通常可带来 15% - 30% 的速度提升。
# 推荐写法。代码耗时:20.6秒
import math
def main(): # 定义到函数中,以减少全部变量使用
size = 10000
for x in range(size):
for y in range(size):
z = math.sqrt(x) + math.sqrt(y)
main()
2. 避免.
2.1 避免模块和函数属性访问
# 不推荐写法。代码耗时:14.5秒
import math
def computeSqrt(size: int):
result = []
for i in range(size):
result.append(math.sqrt(i))
return result
def main():
size = 10000
for _ in range(size):
result = computeSqrt(size)
main()
每次使用.(属性访问操作符时)会触发特定的方法,如__getattribute__()和__getattr__(),这些方法会进行字典操作,因此会带来额外的时间开销。通过from import语句,可以消除属性访问。
# 第一次优化写法。代码耗时:10.9秒
from math import sqrt
def computeSqrt(size: int):
result = []
for i in range(size):
result.append(sqrt(i)) # 避免math.sqrt的使用
return result
def main():
size = 10000
for _ in range(size):
result = computeSqrt(size)
main()
在第 1 节中我们讲到,局部变量的查找会比全局变量更快,因此对于频繁访问的变量sqrt,通过将其改为局部变量可以加速运行。
# 第二次优化写法。代码耗时:9.9秒
import math
def computeSqrt(size: int):
result = []
sqrt = math.sqrt # 赋值给局部变量
for i in range(size):
result.append(sqrt(i)) # 避免math.sqrt的使用
return result
def main():
size = 10000
for _ in range(size):
result = computeSqrt(size)
main()
除了math.sqrt外,computeSqrt函数中还有.的存在,那就是调用list的append方法。通过将该方法赋值给一个局部变量,可以彻底消除computeSqrt函数中for循环内部的.使用。
# 推荐写法。代码耗时:7.9秒
import math
def computeSqrt(size: int):
result = []
append = result.append
sqrt = math.sqrt # 赋值给局部变量
for i in range(size):
append(sqrt(i)) # 避免 result.append 和 math.sqrt 的使用
return result
def main():
size = 10000
for _ in range(size):
result = computeSqrt(size)
main()
2.2 避免类内属性访问
# 不推荐写法。代码耗时:10.4秒
import math
from typing import List
class DemoClass:
def __init__(self, value: int):
self._value = value
def computeSqrt(self, size: int) -> List[float]:
result = []
append = result.append
sqrt = math.sqrt
for _ in range(size):
append(sqrt(self._value))
return result
def main():
size = 10000
for _ in range(size):
demo_instance = DemoClass(size)
result = demo_instance.computeSqrt(size)
main()
避免.的原则也适用于类内属性,访问self._value的速度会比访问一个局部变量更慢一些。通过将需要频繁访问的类内属性赋值给一个局部变量,可以提升代码运行速度。
# 推荐写法。代码耗时:8.0秒
import math
from typing import List
class DemoClass:
def __init__(self, value: int):
self._value = value
def computeSqrt(self, size: int) -> List[float]:
result = []
append = result.append
sqrt = math.sqrt
value = self._value
for _ in range(size):
append(sqrt(value)) # 避免 self._value 的使用
return result
def main():
size = 10000
for _ in range(size):
demo_instance = DemoClass(size)
demo_instance.computeSqrt(size)
main()
3. 避免不必要的抽象
# 不推荐写法,代码耗时:0.55秒
class DemoClass:
def __init__(self, value: int):
self.value = value
@property
def value(self) -> int:
return self._value
@value.setter
def value(self, x: int):
self._value = x
def main():
size = 1000000
for i in range(size):
demo_instance = DemoClass(size)
value = demo_instance.value
demo_instance.value = i
main()
任何时候当你使用额外的处理层(比如装饰器、属性访问、描述器)去包装代码时,都会让代码变慢。大部分情况下,需要重新进行审视使用属性访问器的定义是否有必要,使用getter/setter函数对属性进行访问通常是 C/C++ 程序员遗留下来的代码风格。如果真的没有必要,就使用简单属性。
# 推荐写法,代码耗时:0.33秒
class DemoClass:
def __init__(self, value: int):
self.value = value # 避免不必要的属性访问器
def main():
size = 1000000
for i in range(size):
demo_instance = DemoClass(size)
value = demo_instance.value
demo_instance.value = i
main()
4. 避免数据复制
4.1 避免无意义的数据复制
# 不推荐写法,代码耗时:6.5秒
def main():
size = 10000
for _ in range(size):
value = range(size)
value_list = [x for x in value]
square_list = [x * x for x in value_list]
main()
上面的代码中value_list完全没有必要,这会创建不必要的数据结构或复制。
# 推荐写法,代码耗时:4.8秒
def main():
size = 10000
for _ in range(size):
value = range(size)
square_list = [x * x for x in value] # 避免无意义的复制
main()
另外一种情况是对 Python 的数据共享机制过于偏执,并没有很好地理解或信任 Python 的内存模型,滥用 copy.deepcopy()之类的函数。通常在这些代码中是可以去掉复制操作的。
4.2 交换值时不使用中间变量
# 不推荐写法,代码耗时:0.07秒
def main():
size = 1000000
for _ in range(size):
a = 3
b = 5
temp = a
a = b
b = temp
main()
上面的代码在交换值时创建了一个临时变量temp,如果不借助中间变量,代码更为简洁、且运行速度更快。
# 推荐写法,代码耗时:0.06秒
def main():
size = 1000000
for _ in range(size):
a = 3
b = 5
a, b = b, a # 不借助中间变量
main()
4.3 字符串拼接用join而不是+
# 不推荐写法,代码耗时:2.6秒
import string
from typing import List
def concatString(string_list: List[str]) -> str:
result = ''
for str_i in string_list:
result += str_i
return result
def main():
string_list = list(string.ascii_letters * 100)
for _ in range(10000):
result = concatString(string_list)
main()
当使用a + b拼接字符串时,由于 Python 中字符串是不可变对象,其会申请一块内存空间,将a和b分别复制到该新申请的内存空间中。
# 推荐写法,代码耗时:0.3秒
import string
from typing import List
def concatString(string_list: List[str]) -> str:
return ''.join(string_list) # 使用 join 而不是 +
def main():
string_list = list(string.ascii_letters * 100)
for _ in range(10000):
result = concatString(string_list)
main()
5. 利用if条件的短路特性
# 不推荐写法,代码耗时:0.05秒
from typing import List
def concatString(string_list: List[str]) -> str:
abbreviations = {'cf.', 'e.g.', 'ex.', 'etc.', 'flg.', 'i.e.', 'Mr.', 'vs.'}
abbr_count = 0
result = ''
for str_i in string_list:
if str_i in abbreviations:
result += str_i
return result
def main():
for _ in range(10000):
string_list = ['Mr.', 'Hat', 'is', 'Chasing', 'the', 'black', 'cat', '.']
result = concatString(string_list)
main()
if 条件的短路特性是指对if a and b这样的语句, 当a为False时将直接返回,不再计算b;对于if a or b这样的语句,当a为True时将直接返回,不再计算b。因此, 为了节约运行时间,对于or语句,应该将值为True可能性比较高的变量写在or前,而and应该推后。
# 推荐写法,代码耗时:0.03秒
from typing import List
def concatString(string_list: List[str]) -> str:
abbreviations = {'cf.', 'e.g.', 'ex.', 'etc.', 'flg.', 'i.e.', 'Mr.', 'vs.'}
abbr_count = 0
result = ''
for str_i in string_list:
if str_i[-1] == '.' and str_i in abbreviations: # 利用 if 条件的短路特性
result += str_i
return result
def main():
for _ in range(10000):
string_list = ['Mr.', 'Hat', 'is', 'Chasing', 'the', 'black', 'cat', '.']
result = concatString(string_list)
main()
6. 循环优化
6.1 用for循环代替while循环
# 不推荐写法。代码耗时:6.7秒
def computeSum(size: int) -> int:
sum_ = 0
i = 0
while i < size:
sum_ += i
i += 1
return sum_
def main():
size = 10000
for _ in range(size):
sum_ = computeSum(size)
main()
Python 的for循环比while循环快不少。
# 推荐写法。代码耗时:4.3秒
def computeSum(size: int) -> int:
sum_ = 0
for i in range(size): # for 循环代替 while 循环
sum_ += i
return sum_
def main():
size = 10000
for _ in range(size):
sum_ = computeSum(size)
main()
6.2 使用隐式for循环代替显式for循环
针对上面的例子,更进一步可以用隐式for循环来替代显式for循环
# 推荐写法。代码耗时:1.7秒
def computeSum(size: int) -> int:
return sum(range(size)) # 隐式 for 循环代替显式 for 循环
def main():
size = 10000
for _ in range(size):
sum = computeSum(size)
main()
6.3 减少内层for循环的计算
# 不推荐写法。代码耗时:12.8秒
import math
def main():
size = 10000
sqrt = math.sqrt
for x in range(size):
for y in range(size):
z = sqrt(x) + sqrt(y)
main()
上面的代码中sqrt(x)位于内侧for循环, 每次训练过程中都会重新计算一次,增加了时间开销。
# 推荐写法。代码耗时:7.0秒
import math
def main():
size = 10000
sqrt = math.sqrt
for x in range(size):
sqrt_x = sqrt(x) # 减少内层 for 循环的计算
for y in range(size):
z = sqrt_x + sqrt(y)
main()
7. 使用numba.jit
我们沿用上面介绍过的例子,在此基础上使用numba.jit。 numba可以将 Python 函数 JIT 编译为机器码执行,大大提高代码运行速度。关于numba的更多信息见下面的主页:
http://numba.pydata.org/numba.pydata.org/
# 推荐写法。代码耗时:0.62秒
import numba
@numba.jit
def computeSum(size: float) -> int:
sum = 0
for i in range(size):
sum += i
return sum
def main():
size = 10000
for _ in range(size):
sum = computeSum(size)
main()
8. 选择合适的数据结构
Python 内置的数据结构如str, tuple, list, set, dict底层都是 C 实现的,速度非常快,自己实现新的数据结构想在性能上达到内置的速度几乎是不可能的。
list类似于 C++ 中的std::vector,是一种动态数组。其会预分配一定内存空间,当预分配的内存空间用完,又继续向其中添加元素时,会申请一块更大的内存空间,然后将原有的所有元素都复制过去,之后销毁之前的内存空间,再插入新元素。删除元素时操作类似,当已使用内存空间比预分配内存空间的一半还少时,会另外申请一块小内存,做一次元素复制,之后销毁原有大内存空间。因此,如果有频繁的新增、删除操作,新增、删除的元素数量又很多时,list的效率不高。此时,应该考虑使用collections.deque。collections.deque是双端队列,同时具备栈和队列的特性,能够在两端进行 O(1) 复杂度的插入和删除操作。
list的查找操作也非常耗时。当需要在list频繁查找某些元素,或频繁有序访问这些元素时,可以使用bisect维护list对象有序并在其中进行二分查找,提升查找的效率。
另外一个常见需求是查找极小值或极大值,此时可以使用heapq模块将list转化为一个堆,使得获取最小值的时间复杂度是 O(1) 。
下面的网页给出了常用的 Python 数据结构的各项操作的时间复杂度: TimeComplexity - Python WiKi
python之禅
print写法
name = 'ROSE'
country = 'China'
age = 20
print('hi, my name is {}. im from {}, and im {}'.format(name,country,age))
最简单写法
print(f'hi, my name is {name}, im from {country}, and im {age+1}')
for 循环时使用 enumerate 可返回两个参数,前一个是 index ,第二个是对应参数
for idx,step in enumerate(range(10))
@staticmethod
静态方法, 不强制要求传递参数
@classmethod
类方法, 不需要实例化, 不需要self参数, 但第一个参数需要是表示自身类的cls参数, 可以用来调用类的属性, 类的方法, 实例化对象等
### 类特殊方法
class Test():
def __init__():
pass
def __enter__():
'''使用with语句创建示例时会自动运行此方法'''
pass
‘’‘
with Test() as t:
pass
’‘’
def __exit__():
'''使用with语句创建实例, 在结束时自动调用该方法'''
pass
def __str__():
'''可print(实例)'''
return ‘我是Test类’
def __setattr__, __getattr__, __getattribute__, __delattr__:
'''对属性进行操作'''
pass
def __call__():
'''能让把实例化对象直接当做函数来调用'''
print(1)
'''
a = Test()
in: a()
out: 1
'''
def __contains__, __len__():
'''类作为容器'''
pass
# HDFStore`
with pd.HDFStore('iv_hv.h5') as store:
c = store.keys()
'''matplotlib
':' 点虚线
'-' 实线
'--' 破折线
'-.' 点划线
添加水平垂直线
plt.axhline(y=0,ls=":",c="yellow") 水平直线
plt.axvline(x=4,ls="-",c="green") 垂直直线
'''

自动发邮件
import smtplib
from smtplib import SMTP_SSL
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from email.mime.application import MIMEApplication # 用于添加附件
host_server = 'smtp.qq.com' # qq邮箱smtp服务器
sender_qq = '359582058@qq.com' # 发件人邮箱
pwd = 'bglhxfrujynobhda'qq
pwd = 'EGGZASTFLHVGCBRU'163
receiver = '13918949838@163.com'
mail_title = 'Python自动发送邮件' # 邮件标题
# 邮件正文内容
mail_content = "您好"
msg = MIMEMultipart()
msg["Subject"] = Header(mail_title, 'utf-8')
msg["From"] = sender_qq
msg["To"] = Header("测试邮箱", "utf-8")
msg.attach(MIMEText(mail_content, 'html'))
attachment = MIMEApplication(open('复权.xlsx', 'rb').read())
attachment["Content-Type"] = 'application/octet-stream'
# 给附件重命名
basename = "复权.xlsx"
attachment.add_header('Content-Disposition', 'attachment',
filename=('utf-8', '', basename)) # 注意:此处basename要转换为gbk编码,否则中文会有乱码。
msg.attach(attachment)
try:
smtp = SMTP_SSL(host_server) # ssl登录连接到邮件服务器
smtp.set_debuglevel(1) # 0是关闭,1是开启debug
smtp.ehlo(host_server) # 跟服务器打招呼,告诉它我们准备连接,最好加上这行代码
smtp.login(sender_qq, pwd)
smtp.sendmail(sender_qq, receiver, msg.as_string())
smtp.quit()
print("邮件发送成功")
except smtplib.SMTPException:
print("无法发送邮件")
py23com
from win32com.client import makepy
makepy.main() # 跳出窗口, 创建静态代理static proxy
win32com.client.constant.__d
Rust相关
教程来自于Rust语言圣经
Rust基础知识
Rust基础知识
在线运行测试
fn main(){ println!("Hello, world!") }
安装rust(202302)
macOS
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
显示
Rust is installed now. Great!
则安装成功!
安装C语言编译器
$ xcode-select --install
Windows
先安装Microsoft C++ Build Tools, 勾选安装C++环境. 然后将Rust所需的msvc命令行程序手动添加到环境变量中, 其位于%Visual Studio 安装位置%\VC\Tools\MSVC\%version%\bin\Hostx64\x64下.
在RUSTUP-INIT下载系统对应的版本,
PS C:\Users\Hehongyuan> rustup-init.exe
......
Current installation options:
default host triple: x86_64-pc-windows-msvc
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
cargo
上面的命令使用 cargo new 创建一个项目,项目名是 world_hello, 该项目的结构和配置文件都是由 cargo 生成,意味着我们的项目被 cargo 所管理.
下面来看看创建的项目结构:
$ tree
.
├── .git
├── .gitignore
├── Cargo.toml
└── src
└── main.rs
运行项目
有两种方式可以运行项目:
-
cargo run -
手动编译和运行项目
首先来看看第一种方式,在之前创建的 world_hello 目录下运行:
$ cargo run
Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/world_hello`
Hello, world!
好了,你已经看到程序的输出:"Hello, world"。
如果你安装的 Rust 的 host triple 是 x86_64-pc-windows-msvc 并确认 Rust 已经正确安装,但在终端上运行上述命令时,出现类似如下的错误摘要 linking with `link.exe` failed: exit code: 1181,请使用 Visual Studio Installer 安装 Windows SDK。
上述代码,cargo run 首先对项目进行编译,然后再运行,因此它实际上等同于运行了两个指令,下面我们手动试一下编译和运行项目:
编译
$ cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
运行
$ ./target/debug/world_hello
Hello, world!
行云流水,但谈不上一气呵成。在调用的时候,路径 ./target/debug/world_hello 中有一个明晃晃的 debug 字段,没错我们运行的是 debug 模式,在这种模式下,代码的编译速度会非常快,可是福兮祸所依,运行速度就慢了. 原因是,在 debug 模式下,Rust 编译器不会做任何的优化,只为了尽快的编译完成,让开发流程更加顺畅。
想要高性能的代码怎么办? 简单,添加 --release 来编译:
cargo run --releasecargo build --release
试着运行一下我们高性能的 release 程序:
$ ./target/release/world_hello
Hello, world!
cargo check
当项目大了后,cargo run 和 cargo build 不可避免的会变慢,那么有没有更快的方式来验证代码的正确性呢?
cargo check 是我们在代码开发过程中最常用的命令,它的作用很简单:快速的检查一下代码能否编译通过。因此该命令速度会非常快,能节省大量的编译时间。
$ cargo check
Checking world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
Rust 虽然编译速度还行,但是还是不能与 Go 语言相提并论,因为 Rust 需要做很多复杂的编译优化和语言特性解析,甚至连如何优化编译速度都成了一门学问: 优化编译速度
Cargo.toml 和 Cargo.lock
Cargo.toml 和 Cargo.lock 是 cargo 的核心文件,它的所有活动均基于此二者。
-
Cargo.toml是cargo特有的项目数据描述文件。它存储了项目的所有元配置信息,如果 Rust 开发者希望 Rust 项目能够按照期望的方式进行构建、测试和运行,那么,必须按照合理的方式构建Cargo.toml。 -
Cargo.lock文件是cargo工具根据同一项目的toml文件生成的项目依赖详细清单,因此我们一般不用修改
什么情况下该把
Cargo.lock上传到 git 仓库里?很简单,当你的项目是一个可运行的程序时,就上传Cargo.lock,如果是一个依赖库项目,那么请把它添加到.gitignore中
现在用 VSCode 打开上面创建的"世界,你好"项目,然后进入根目录的 Cargo.toml 文件,可以看到该文件包含不少信息:
package 配置段落
package 中记录了项目的描述信息,典型的如下:
[package]
name = "world_hello"
version = "0.1.0"
edition = "2021"
name 字段定义了项目名称,version 字段定义当前版本,新项目默认是 0.1.0,edition 字段定义了使用的 Rust 大版本
定义项目依赖
使用 cargo 工具的最大优势就在于,能够对该项目的各种依赖项进行方便、统一和灵活的管理。
在 Cargo.toml 中,主要通过各种依赖段落来描述该项目的各种依赖项:
- 基于 Rust 官方仓库
crates.io,通过版本说明来描述 - 基于项目源代码的 git 仓库地址,通过 URL 来描述
- 基于本地项目的绝对路径或者相对路径,通过类 Unix 模式的路径来描述
这三种形式具体写法如下:
[dependencies]
rand = "0.3"
hammer = { version = "0.5.0"}
color = { git = "https://github.com/bjz/color-rs" }
geometry = { path = "crates/geometry" }
变量绑定与解构
变量绑定
在其它语言中,我们用 var a = "hello world" 的方式给 a 赋值,也就是把等式右边的 "hello world" 字符串赋值给变量 a ,而在 Rust 中,我们这样写: let a = "hello world" ,同时给这个过程起了另一个名字:变量绑定。
为何不用赋值而用绑定呢(其实你也可以称之为赋值,但是绑定的含义更清晰准确)?这里就涉及 Rust 最核心的原则——所有权,简单来讲,任何内存对象都是有主人的,而且一般情况下完全属于它的主人,绑定就是把这个对象绑定给一个变量.
变量可变性
Rust 的变量在默认情况下是不可变的。前文提到,这是 Rust 团队为我们精心设计的语言特性之一,让我们编写的代码更安全,性能也更好。当然你可以通过 mut 关键字让变量变为可变的,让设计更灵活。
如果变量 a 不可变,那么一旦为它绑定值,就不能再修改 a:
fn main() { let x = 5; println!("The value of x is: {}", x); x = 6; println!("The value of x is: {}", x); }
在 Rust 中,可变性很简单,只要在变量名前加一个 mut 即可, 而且这种显式的声明方式还会给后来人传达这样的信息:嗯,这个变量在后面代码部分会发生改变。
fn main() { let mut x = 5; println!("The value of x is: {}", x); x = 6; println!("The value of x is: {}", x); }
如果创建了一个变量却不在任何地方使用它,Rust 通常会给你一个警告,因为这可能会是个 BUG。但是有时创建一个不会被使用的变量是有用的,比如你正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头:
fn main() { let _x = 5; let y = 10; }
变量解构
let 表达式不仅仅用于变量的绑定,还能进行复杂变量的解构:从一个相对复杂的变量中,匹配出该变量的一部分内容:
fn main() { let (a, mut b): (bool,bool) = (true, false); // a = true,不可变; b = false,可变 println!("a = {:?}, b = {:?}", a, b); b = true; assert_eq!(a, b); }
解构式赋值
在Rust 1.59版本后,我们可以在赋值语句的左式中使用元组、切片和结构体模式了。
struct Struct { e: i32 } fn main() { let (a, b, c, d, e); (a, b) = (1, 2); // _ 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 _ [c, .., d, _] = [1, 2, 3, 4, 5]; Struct { e, .. } = Struct { e: 5 }; assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e]); }
这种使用方式跟之前的 let 保持了一致性,但是 let 会重新绑定,而这里仅仅是对之前绑定的变量进行再赋值。
变量和常量之间的差异
变量的值不能更改可能让你想起其他另一个很多语言都有的编程概念:常量(constant)。与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:
- 常量不允许使用
mut。常量不仅仅默认不可变,而且自始至终不可变,因为常量在编译完成后,已经确定它的值。 - 常量使用
const关键字而不是let关键字来声明,并且值的类型必须标注。
常量可以在任意作用域内声明,包括全局作用域,在声明的作用域内,常量在程序运行的整个过程中都有效。
变量遮蔽(shadowing)
Rust 允许声明相同的变量名,在后面声明的变量会遮蔽掉前面声明的,如下所示:
fn main() { let x = 5; // 在main函数的作用域内对之前的x进行遮蔽 let x = x + 1; { // 在当前的花括号作用域内,对之前的x进行遮蔽 let x = x * 2; println!("The value of x in the inner scope is: {}", x); } println!("The value of x is: {}", x); }
这个程序首先将数值 5 绑定到 x,然后通过重复使用 let x = 来遮蔽之前的 x,并取原来的值加上 1,所以 x 的值变成了 6。第三个 let 语句同样遮蔽前面的 x,取之前的值并乘上 2,得到的 x 最终值为 12。
这和 mut 变量的使用是不同的,第二个 let 生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配
,而 mut 声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好.
变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字
所有权,引用与借用
所有权
所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,在计算机语言不断演变过程中,出现了三种流派:
- 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
- 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++
- 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查
其中 Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。
一段不安全的代码
先来看看一段来自 C 语言的糟糕代码:
int* foo() {
int a; // 变量a的作用域开始
a = 100;
char *c = "xyz"; // 变量c的作用域开始
return &a;
} // 变量a和c的作用域结束
这段代码虽然可以编译通过,但是其实非常糟糕,变量 a 和 c 都是局部变量,函数结束后将局部变量 a 的地址返回,但局部变量 a 存在栈中,在离开作用域后,a 所申请的栈上内存都会被系统回收,从而造成了 悬空指针(Dangling Pointer) 的问题。这是一个非常典型的内存安全问题,虽然编译可以通过,但是运行的时候会出现错误, 很多编程语言都存在。再来看变量 c,c 的值是常量字符串,存储于常量区,可能这个函数我们只调用了一次,也可能我们不再会使用这个字符串,但 "xyz" 只有当整个程序结束后系统才能回收这片内存。
栈(Stack)与堆(Heap)
栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。
栈
栈按照顺序存储值并以相反顺序取出值,这也被称作后进先出。增加数据叫做进栈,移出数据则叫做出栈。
因为上述的实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,你将无法取到你想要的数据。
堆
与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。
当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针, 该过程被称为在堆上分配内存,有时简称为 “分配”(allocating)。
接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。
性能区别
写入方面:入栈比在堆上分配内存要快,因为入栈时操作系统无需分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备。
读取方面:得益于 CPU 高速缓存,使得处理器可以减少对内存的访问,高速缓存和内存的访问速度差异在 10 倍以上!栈数据往往可以直接存储在 CPU 高速缓存中,而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存。
因此,处理器处理和分配在栈上的数据会比在堆上的数据更加高效。
所有权与堆栈
当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。
因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。
对于其他很多编程语言,你确实无需理解堆栈的原理,但是在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助。
所有权原则
理解了堆栈,接下来看一下关于所有权的规则,首先请谨记以下规则:
- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
变量作用域
作用域是一个变量在程序中有效的范围, 假如有这样一个变量:
#![allow(unused)] fn main() { let s = "hello"; }
变量 s 绑定到了一个字符串字面值,该字符串字面值是硬编码到程序代码中的。s 变量从声明的点开始直到当前作用域的结束都是有效的:
#![allow(unused)] fn main() { { // s 在这里无效,它尚未声明 let s = "hello"; // 从此处起,s 是有效的 // 使用 s } // 此作用域已结束,s不再有效 }
简而言之,s 从创建伊始就开始有效,然后有效期持续到它离开作用域为止,可以看出,就作用域来说,Rust 语言跟其他编程语言没有区别。
简单介绍 String 类型
我们已经见过字符串字面值 let s ="hello",s 是被硬编码进程序里的字符串值(类型为 &str )。字符串字面值是很方便的,但是它并不适用于所有场景。原因有二:
- 字符串字面值是不可变的,因为被硬编码到程序代码中
- 并非所有字符串的值都能在编写代码时得知
例如,字符串是需要程序运行时,通过用户动态输入然后存储在内存中的,这种情况,字符串字面值就完全无用武之地。 为此,Rust 为我们提供动态字符串类型: String, 该类型被分配到堆上,因此可以动态伸缩,也就能存储在编译时大小未知的文本。
可以使用下面的方法基于字符串字面量来创建 String 类型:
#![allow(unused)] fn main() { let s = String::from("hello"); }
:: 是一种调用操作符,这里表示调用 String 中的 from 方法,因为 String 存储在堆上是动态的,你可以这样修改它:
#![allow(unused)] fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() 在字符串后追加字面值 println!("{}", s); // 将打印 `hello, world!` }
变量绑定背后的数据交互
转移所有权
先来看一段代码:
#![allow(unused)] fn main() { let x = 5; let y = x; }
代码背后的逻辑很简单, 将 5 绑定到变量 x;接着拷贝 x 的值赋给 y,最终 x 和 y 都等于 5,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。
这种拷贝不消耗性能吗?实际上,这种栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32,4 个字节)的内存即可,因此在这种情况下,拷贝的速度远比在堆上创建内存来得快的多。实际上,上一章我们讲到的 Rust 基本类型都是通过自动拷贝的方式来赋值的,就像上面代码一样。
然后再来看一段代码:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1; }
对于基本类型(存储在栈上),Rust 会自动拷贝,但是 String 不是基本类型,而且是存储在堆上的,因此不能自动拷贝。
实际上, String 类型是一个复杂类型,由存储在栈中的堆指针、字符串长度、字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存,至于长度和容量,如果你有 Go 语言的经验,这里就很好理解:容量是堆内存分配空间的大小,长度是目前已经使用的大小。
假定一个值可以拥有两个所有者,会发生什么呢?当变量离开作用域后,Rust 会自动调用 drop 函数并清理变量的堆内存。不过由于两个 String 变量指向了同一位置。这就有了一个问题:当 s1 和 s2 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
因此,Rust 实际这样解决问题:当 s1 赋予 s2 后,Rust 认为 s1 不再有效,因此也无需在 s1 离开作用域后 drop 任何东西,这就是把所有权从 s1 转移给了 s2,s1 在被赋予 s2 后就马上失效了。
再来看看,在所有权转移后再来使用旧的所有者,会发生什么:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); }
回头看之前的规则
- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
如果你在其他语言中听说过术语 浅拷贝(shallow copy) 和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据听起来就像浅拷贝,但是又因为 Rust 同时使第一个变量 s1 无效了,因此这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1 被移动到了 s2 中。那么具体发生了什么,用一张图简单说明:
这样就解决了我们之前的问题,s1 不再指向任何数据,只有 s2 是有效的,当 s2 离开作用域,它就会释放内存。 相信此刻,你应该明白了,为什么 Rust 称呼 let a = b 为变量绑定了吧?
再来看一段代码:
fn main() { let x: &str = "hello, world"; let y = x; println!("{},{}",x,y); }
这段代码,大家觉得会否报错?如果参考之前的 String 所有权转移的例子,那这段代码也应该报错才是,但是实际上呢?
这段代码和之前的 String 有一个本质上的区别:在 String 的例子中 s1 持有了通过String::from("hello") 创建的值的所有权,而这个例子中,x 只是引用了存储在二进制中的字符串 "hello, world",并没有持有所有权。
因此 let y = x 中,仅仅是对该引用进行了拷贝,此时 y 和 x 都引用了同一个字符串。学习了 "引用与借用" 后,自然而言就会理解。
克隆(深拷贝)
首先,Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。
如果我们确实需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的方法。
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); }
这段代码能够正常运行,因此说明 s2 确实完整的复制了 s1 的数据。
如果代码性能无关紧要,例如初始化程序时,或者在某段时间只会执行一次时,你可以使用 clone 来简化编程。但是对于执行较为频繁的代码(热点路径),使用 clone 会极大的降低程序性能,需要小心使用!
拷贝(浅拷贝)
浅拷贝只发生在栈上,因此性能很高,在日常编程中,浅拷贝无处不在。
再回到之前看过的例子:
#![allow(unused)] fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); }
但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过依然实现了类似深拷贝的效果 —— 没有报所有权的错误。
原因是像整型这样的基本类型在编译时是已知大小的,会被存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效(x、y 都仍然有效)。换句话说,这里没有深浅拷贝的区别,因此这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它(可以理解成在栈上做了深拷贝)。
Rust 有一个叫做 Copy 的特征,可以用在类似整型这样在栈中存储的类型。如果一个类型拥有 Copy 特征,一个旧的变量在被赋值给其他变量后仍然可用。
那么什么类型是可 Copy 的呢?可以查看给定类型的文档来确认,不过作为一个通用的规则: 任何基本类型的组合可以 Copy ,不需要分配内存或某种形式资源的类型是可以 Copy 的。如下是一些 Copy 的类型:
- 所有整数类型,比如
u32。 - 布尔类型,
bool,它的值是true和false。 - 所有浮点数类型,比如
f64。 - 字符类型,
char。 - 元组,当且仅当其包含的类型也都是
Copy的时候。比如,(i32, i32)是Copy的,但(i32, String)就不是。 - 不可变引用
&T,但是注意: 可变引用&mut T是不可以 Copy的
函数传值与返回
将值传递给函数,一样会发生 移动 或者 复制,就跟 let 语句一样,下面的代码展示了所有权、作用域的规则:
fn main() { let s = String::from("hello"); // s 进入作用域 takes_ownership(s); // s 的值移动到函数里 ... // ... 所以到这里不再有效 let x = 5; // x 进入作用域 makes_copy(x); // x 应该移动函数里, // 但 i32 是 Copy 的,所以在后面可继续使用 x } // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走, // 所以不会有特殊操作 fn takes_ownership(some_string: String) { // some_string 进入作用域 println!("{}", some_string); } // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放 fn makes_copy(some_integer: i32) { // some_integer 进入作用域 println!("{}", some_integer); } // 这里,some_integer 移出作用域。不会有特殊操作
你可以尝试在 takes_ownership 之后,再使用 s,看看如何报错?例如添加一行 println!("在move进函数后继续使用s: {}",s);。
同样的,函数返回值也有所有权,例如:
fn main() { let s1 = gives_ownership(); // gives_ownership 将返回值 // 移给 s1 let s2 = String::from("hello"); // s2 进入作用域 let s3 = takes_and_gives_back(s2); // s2 被移动到 // takes_and_gives_back 中, // 它也将返回值移给 s3 } // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走, // 所以什么也不会发生。s1 移出作用域并被丢弃 fn gives_ownership() -> String { // gives_ownership 将返回值移动给 // 调用它的函数 let some_string = String::from("hello"); // some_string 进入作用域. some_string // 返回 some_string 并移出给调用的函数 } // takes_and_gives_back 将传入字符串并返回该值 fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域 a_string // 返回 a_string 并移出给调用的函数 }
所有权很强大,避免了内存的不安全性,但是也带来了一个新麻烦: 总是把一个值传来传去来使用它。 传入一个函数,很可能还要从该函数传出去,结果就是语言表达变得非常啰嗦,幸运的是,Rust 提供了新功能解决这个问题。
引用与借用
上节中提到,如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。 Rust 能否像其它编程语言一样,使用某个变量的指针或者引用呢?答案是可以。
Rust 通过 借用(Borrowing) 这个概念来达成上述的目的,获取变量的引用,称之为借用(borrowing)。
引用与解引用
常规引用是一个指针类型,指向了对象存储的内存地址。在下面代码中,我们创建一个 i32 值的引用 y,然后使用解引用运算符*来解出 y 所使用的值:
fn main() { let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y); }
变量 x 存放了一个 i32 值 5。y 是 x 的一个引用。可以断言 x 等于 5。然而,如果希望对 y 的值做出断言,必须使用 *y 来解出引用所指向的值(也就是解引用)。一旦解引用了 y,就可以访问 y 所指向的整型值并可以与 5 做比较。
相反如果尝试编写 assert_eq!(5, y);,则会得到如下编译错误:
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}` // 无法比较整数类型和引用类型
|
= help: the trait `std::cmp::PartialEq<&{integer}>` is not implemented for
`{integer}`
不允许比较整数与引用,因为它们是不同的类型。必须使用解引用运算符解出引用所指向的值。
不可变引用
下面的代码,我们用 s1 的引用作为参数传递给 calculate_length 函数,而不是把 s1 的所有权转移给该函数:
fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); println!("The length of '{}' is {}.", s1, len); } fn calculate_length(s: &String) -> usize { s.len() }
能注意到两点:
- 无需像上章一样:先通过函数参数传入所有权,然后再通过函数返回来传出所有权,代码更加简洁
calculate_length的参数s类型从String变为&String
这里,& 符号即是引用,它们允许你使用值,但是不获取所有权,如图所示:

通过 &s1 语法,我们创建了一个指向 s1 的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。
同理,函数 calculate_length 使用 & 来表明参数 s 的类型是一个引用:
#![allow(unused)] fn main() { fn calculate_length(s: &String) -> usize { // s 是对 String 的引用 s.len() } // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权, // 所以什么也不会发生 }
因此光借用满足不了我们,如果尝试修改借用的变量呢?
fn main() { let s = String::from("hello"); change(&s); } fn change(some_string: &String) { some_string.push_str(", world"); }
很不幸,修改错了:
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
7 | fn change(some_string: &String) {
| ------- help: consider changing this to be a mutable reference: `&mut String`
------- 帮助:考虑将该参数类型修改为可变的引用: `&mut String`
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
`some_string`是一个`&`类型的引用,因此它指向的数据无法进行修改
正如变量默认不可变一样,引用指向的值默认也是不可变的,没事,来一起看看如何解决这个问题。
可变引用
只需要一个小调整,即可修复上面代码的错误:
fn main() { let mut s = String::from("hello"); change(&mut s); } fn change(some_string: &mut String) { some_string.push_str(", world"); }
首先,声明 s 是可变类型,其次创建一个可变的引用 &mut s 和接受可变引用参数 some_string: &mut String 的函数。
可变引用同时只能存在一个
不过可变引用并不是随心所欲、想用就用的,它有一个很大的限制: 同一作用域,特定数据只能有一个可变引用:
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }
以上代码会报错:
error[E0499]: cannot borrow `s` as mutable more than once at a time 同一时间无法对 `s` 进行两次可变借用
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here 首个可变引用在这里借用
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here 第二个可变引用在这里借用
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here 第一个借用在这里使用
这段代码出错的原因在于,第一个可变借用 r1 必须要持续到最后一次使用的位置 println!,在 r1 创建和最后一次使用之间,我们又尝试创建第二个可变借用 r2。
对于新手来说,这个特性绝对是一大拦路虎,也是新人们谈之色变的编译器 borrow checker 特性之一,不过各行各业都一样,限制往往是出于安全的考虑,Rust 也一样。
这种限制的好处就是使 Rust 在编译期就避免数据竞争,数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。而 Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
很多时候,大括号可以帮我们解决一些编译不通过的问题,通过手动限制变量的作用域:
#![allow(unused)] fn main() { let mut s = String::from("hello"); { let r1 = &mut s; } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用 let r2 = &mut s; }
可变引用与不可变引用不能同时存在
下面的代码会导致一个错误:
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; // 没问题 let r2 = &s; // 没问题 let r3 = &mut s; // 大问题 println!("{}, {}, and {}", r1, r2, r3); }
错误如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
// 无法借用可变 `s` 因为它已经被借用了不可变
--> src/main.rs:6:14
|
4 | let r1 = &s; // 没问题
| -- immutable borrow occurs here 不可变借用发生在这里
5 | let r2 = &s; // 没问题
6 | let r3 = &mut s; // 大问题
| ^^^^^^ mutable borrow occurs here 可变借用发生在这里
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here 不可变借用在这里使用
注意,引用的作用域
s从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号}
NLL
对于这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(})结束前就不再被使用的代码位置。
虽然这种借用错误有的时候会让我们很郁闷,但是你只要想想这是 Rust 提前帮你发现了潜在的 BUG,其实就开心了,虽然减慢了开发速度,但是从长期来看,大幅减少了后续开发和运维成本。
悬垂引用(Dangling References)
悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。
让我们尝试创建一个悬垂引用,Rust 会抛出一个编译时错误:
fn main() { let reference_to_nothing = dangle(); } fn dangle() -> &String { let s = String::from("hello"); &s }
这里是错误:
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| ~~~~~~~~
仔细看看 dangle 代码的每一步到底发生了什么:
#![allow(unused)] fn main() { fn dangle() -> &String { // dangle 返回一个字符串的引用 let s = String::from("hello"); // s 是一个新字符串 &s // 返回字符串 s 的引用 } // 这里 s 离开作用域并被丢弃。其内存被释放。 // 危险! }
因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放, 但是此时我们又尝试去返回它的引用。这意味着这个引用会指向一个无效的 String,这可不对!
其中一个很好的解决方法是直接返回 String:
#![allow(unused)] fn main() { fn no_dangle() -> String { let s = String::from("hello"); s } }
这样就没有任何错误了,最终 String 的 所有权被转移给外面的调用者。
借用规则总结
总的来说,借用规则如下:
- 同一时刻,你只能拥有要么一个可变引用, 要么任意多个不可变引用
- 引用必须总是有效的
复合类型
顾名思义,复合类型是由其它类型组合而成的,最典型的就是结构体 struct 和枚举 enum。
来看一段代码,它使用我们之前学过的内容来构建文件操作:
#![allow(unused_variables)] type File = String; fn open(f: &mut File) -> bool { true } fn close(f: &mut File) -> bool { true } #[allow(dead_code)] fn read(f: &mut File, save_to: &mut Vec<u8>) -> ! { unimplemented!() } fn main() { let mut f1 = File::from("f1.txt"); open(&mut f1); //read(&mut f1, &mut vec![]); close(&mut f1); }
接下来我们的学习非常类似原型设计:有的方法只提供 API 接口,但是不提供具体实现。此外,有的变量在声明之后并未使用,因此在这个阶段我们需要排除一些编译器噪音(Rust 在编译的时候会扫描代码,变量声明后未使用会以 warning 警告的形式进行提示),引入 #![allow(unused_variables)] 属性标记,该标记会告诉编译器忽略未使用的变量,不要抛出 warning 警告,具体的常见编译器属性你可以在这里查阅:编译器属性标记。
read 函数也非常有趣,它返回一个 ! 类型,这个表明该函数是一个发散函数,不会返回任何值,包括 ()。unimplemented!() 告诉编译器该函数尚未实现,unimplemented!() 标记通常意味着我们期望快速完成主要代码,回头再通过搜索这些标记来完成次要代码,类似的标记还有 todo!(),当代码执行到这种未实现的地方时,程序会直接报错。你可以反注释 read(&mut f1, &mut vec![]); 这行,然后再观察下结果。
同时,从代码设计角度来看,关于文件操作的类型和函数应该组织在一起,散落得到处都是,是难以管理和使用的。而且通过 open(&mut f1) 进行调用,也远没有使用 f1.open() 来调用好,这就体现出了只使用基本类型的局限性:无法从更高的抽象层次去简化代码。
接下来,我们将引入一个高级数据结构 —— 结构体 struct,来看看复合类型是怎样更好的解决这类问题。 开始之前,先来看看 Rust 的重点也是难点:字符串 String 和 &str。
字符串
首先来看段很简单的代码:
fn main() { let my_name = "Pascal"; greet(my_name); } fn greet(name: String) { println!("Hello, {}!", name); }
greet 函数接受一个字符串类型的 name 参数,然后打印到终端控制台中,非常好理解,你们猜猜,这段代码能否通过编译?
error[E0308]: mismatched types
--> src/main.rs:3:11
|
3 | greet(my_name);
| ^^^^^^^
| |
| expected struct `std::string::String`, found `&str`
| help: try using a conversion method: `my_name.to_string()`
error: aborting due to previous error
Bingo,果然报错了,编译器提示 greet 函数需要一个 String 类型的字符串,却传入了一个 &str 类型的字符串.
在讲解字符串之前,先来看看什么是切片?
切片(slice)
它允许你引用集合中部分连续的元素序列,而不是引用整个集合。
对于字符串而言,切片就是对 String 类型中某一部分的引用,它看起来像这样:
#![allow(unused)] fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
hello 没有引用整个 String s,而是引用了 s 的一部分内容,通过 [0..5] 的方式来指定。
这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,也就是这是一个 右半开区间。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引 - 开始索引 的方式计算得来的。
对于 let world = &s[6..11]; 来说,world 是一个切片,该切片的指针指向 s 的第 7 个字节(索引从 0 开始, 6 是第 7 个字节),且该切片的长度是 5 个字节。
这两个是等效的:
#![allow(unused)] fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; }
同样的,如果你的切片想要包含 String 的最后一个字节,则可以这样使用:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[4..len]; let slice = &s[4..]; }
你也可以截取完整的 String 切片:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..]; }
在对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界,例如中文在 UTF-8 中占用三个字节,下面的代码就会崩溃:
#![allow(unused)] fn main() { let s = "中国人"; let a = &s[0..2]; println!("{}",a); }因为我们只取
s字符串的前两个字节,但是本例中每个汉字占用三个字节,因此没有落在边界处,也就是连中字都取不完整,此时程序会直接崩溃退出,如果改成&s[0..3],则可以正常通过编译。 因此,当你需要对字符串做切片索引操作时,需要格外小心这一点
字符串切片的类型标识是 &str,因此我们可以这样声明一个函数,输入 String 类型,返回它的切片: fn first_word(s: &String) -> &str 。
有了切片就可以写出这样的代码:
fn main() { let mut s = String::from("hello world"); let word = first_word(&s); s.clear(); // error! println!("the first word is: {}", word); } fn first_word(s: &String) -> &str { &s[..1] }
编译器报错如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {}", word);
| ---- immutable borrow later used here
回忆一下借用的规则:当我们已经有了可变借用时,就无法再拥有不可变的借用。因为 clear 需要清空改变 String,因此它需要一个可变借用(利用 VSCode 可以看到该方法的声明是 pub fn clear(&mut self) ,参数是对自身的可变借用 );而之后的 println! 又使用了不可变借用,也就是在 s.clear() 处可变借用与不可变借用试图同时生效,因此编译无法通过。
从上述代码可以看出,Rust 不仅让我们的 API 更加容易使用,而且也在编译期就消除了大量错误!
其它切片
因为切片是对集合的部分引用,因此不仅仅字符串有切片,其它集合类型也有,例如数组:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]); }
该数组切片的类型是 &[i32],数组切片和字符串切片的工作方式是一样的.
字符串字面量是切片
之前提到过字符串字面量,但是没有提到它的类型:
#![allow(unused)] fn main() { let s = "Hello, world!"; }
实际上,s 的类型是 &str,因此你也可以这样声明:
#![allow(unused)] fn main() { let s: &str = "Hello, world!"; }
该切片指向了程序可执行文件中的某个点,这也是为什么字符串字面量是不可变的,因为 &str 是一个不可变引用。
了解完切片,可以进入本节的正题了。
什么是字符串?
顾名思义,字符串是由字符组成的连续集合,但是在上一节中我们提到过,Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间。
Rust 在语言级别,只有一种字符串类型: str,它通常是以引用类型出现 &str,也就是上文提到的字符串切片。虽然语言级别只有上述的 str 类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String 类型。
str 类型是硬编码进可执行文件,也无法被修改,但是 String 则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String 类型和 &str 字符串切片类型,这两个类型都是 UTF-8 编码。
除了 String 类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString, OsStr, CsString 和 CsStr 等,注意到这些名字都以 String 或者 Str 结尾了吗?它们分别对应的是具有所有权和被借用的变量。
String 与 &str 的转换
在之前的代码中,已经见到好几种从 &str 类型生成 String 类型的操作:
String::from("hello,world")"hello,world".to_string()
那么如何将 String 类型转为 &str 类型呢?答案很简单,取引用即可:
fn main() { let s = String::from("hello,world!"); say_hello(&s); say_hello(&s[..]); say_hello(s.as_str()); } fn say_hello(s: &str) { println!("{}",s); }
实际上这种灵活用法是因为 deref 隐式强制转换,具体我们会在 Deref 特征进行详细讲解。
字符串索引
在其它语言中,使用索引的方式访问字符串的某个字符或者子串是很正常的行为,但是在 Rust 中就会报错:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let h = s1[0]; }
该代码会产生如下错误:
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
深入字符串内部
字符串的底层的数据存储格式实际上是[ u8 ],一个字节数组。对于 let hello = String::from("Hola"); 这行代码来说,Hola 的长度是 4 个字节,因为 "Hola" 中的每个字母在 UTF-8 编码中仅占用 1 个字节,但是对于下面的代码呢?
#![allow(unused)] fn main() { let hello = String::from("中国人"); }
如果问你该字符串多长,你可能会说 3,但是实际上是 9 个字节的长度,因为大部分常用汉字在 UTF-8 中的长度是 3 个字节,因此这种情况下对 hello 进行索引,访问 &hello[0] 没有任何意义,因为你取不到 中 这个字符,而是取到了这个字符三个字节中的第一个字节,这是一个非常奇怪而且难以理解的返回值。
字符串的不同表现形式
现在看一下用梵文写的字符串 “नमस्ते”, 它底层的字节数组如下形式:
#![allow(unused)] fn main() { [224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135] }
长度是 18 个字节,这也是计算机最终存储该字符串的形式。如果从字符的形式去看,则是:
#![allow(unused)] fn main() { ['न', 'म', 'स', '्', 'त', 'े'] }
但是这种形式下,第四和六两个字母根本就不存在,没有任何意义,接着再从字母串的形式去看:
#![allow(unused)] fn main() { ["न", "म", "स्", "ते"] }
所以,可以看出来 Rust 提供了不同的字符串展现方式,这样程序可以挑选自己想要的方式去使用,而无需去管字符串从人类语言角度看长什么样。
还有一个原因导致了 Rust 不允许去索引字符串:因为索引操作,我们总是期望它的性能表现是 O(1),然而对于 String 类型来说,无法保证这一点,因为 Rust 可能需要从 0 开始去遍历字符串来定位合法的字符。
字符串切片
前文提到过,字符串切片是非常危险的操作,因为切片的索引是通过字节来进行,但是字符串又是 UTF-8 编码,因此你无法保证索引的字节刚好落在字符的边界上,例如:
#![allow(unused)] fn main() { let hello = "中国人"; let s = &hello[0..2]; }
运行上面的程序,会直接造成崩溃:
thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '中' (bytes 0..3) of `中国人`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这里提示的很清楚,我们索引的字节落在了 中 字符的内部,这种返回没有任何意义。
因此在通过索引区间来访问字符串时,需要格外的小心,一不注意,就会导致你程序的崩溃!
操作字符串
由于 String 是可变字符串,下面介绍 Rust 字符串的修改,添加,删除等常用方法:
追加 (Push)
在字符串尾部可以使用 push() 方法追加字符 char,也可以使用 push_str() 方法追加字符串字面量。这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut 关键字修饰。
示例代码如下:
fn main() { let mut s = String::from("Hello "); s.push_str("rust"); println!("追加字符串 push_str() -> {}", s); s.push('!'); println!("追加字符 push() -> {}", s); }
代码运行结果:
追加字符串 push_str() -> Hello rust
追加字符 push() -> Hello rust!
插入 (Insert)
可以使用 insert() 方法插入单个字符 char,也可以使用 insert_str() 方法插入字符串字面量,与 push() 方法不同,这俩方法需要传入两个参数,第一个参数是字符(串)插入位置的索引,第二个参数是要插入的字符(串),索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut 关键字修饰。
示例代码如下:
fn main() { let mut s = String::from("Hello rust!"); s.insert(5, ','); println!("插入字符 insert() -> {}", s); s.insert_str(6, " I like"); println!("插入字符串 insert_str() -> {}", s); }
代码运行结果:
插入字符 insert() -> Hello, rust!
插入字符串 insert_str() -> Hello, I like rust!
替换 (Replace)
如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace() 方法。与替换有关的方法有三个。
1、replace
该方法可适用于 String 和 &str 类型。replace() 方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
fn main() { let string_replace = String::from("I like rust. Learning rust is my favorite!"); let new_string_replace = string_replace.replace("rust", "RUST"); dbg!(new_string_replace); }
代码运行结果:
new_string_replace = "I like RUST. Learning RUST is my favorite!"
2、replacen
该方法可适用于 String 和 &str 类型。replacen() 方法接收三个参数,前两个参数与 replace() 方法一样,第三个参数则表示替换的个数。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
fn main() { let string_replace = "I like rust. Learning rust is my favorite!"; let new_string_replacen = string_replace.replacen("rust", "RUST", 1); dbg!(new_string_replacen); }
代码运行结果:
new_string_replacen = "I like RUST. Learning rust is my favorite!"
3、replace_range
该方法仅适用于 String 类型。replace_range 接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。该方法需要使用 mut 关键字修饰。
示例代码如下:
fn main() { let mut string_replace_range = String::from("I like rust!"); string_replace_range.replace_range(7..8, "R"); dbg!(string_replace_range); }
代码运行结果:
string_replace_range = "I like Rust!"
删除 (Delete)
与字符串删除相关的方法有 4 个,他们分别是 pop(),remove(),truncate(),clear()。这四个方法仅适用于 String 类型。
1、 pop —— 删除并返回字符串的最后一个字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是一个 Option 类型,如果字符串为空,则返回 None。
示例代码如下:
fn main() { let mut string_pop = String::from("rust pop 中文!"); let p1 = string_pop.pop(); let p2 = string_pop.pop(); dbg!(p1); dbg!(p2); dbg!(string_pop); }
代码运行结果:
p1 = Some(
'!',
)
p2 = Some(
'文',
)
string_pop = "rust pop 中"
2、 remove —— 删除并返回字符串中指定位置的字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是删除位置的字符串,只接收一个参数,表示该字符起始索引位置。remove() 方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
fn main() { let mut string_remove = String::from("测试remove方法"); println!( "string_remove 占 {} 个字节", std::mem::size_of_val(string_remove.as_str()) ); // 删除第一个汉字 string_remove.remove(0); // 下面代码会发生错误 // string_remove.remove(1); // 直接删除第二个汉字 // string_remove.remove(3); dbg!(string_remove); }
代码运行结果:
string_remove 占 18 个字节
string_remove = "试remove方法"
3、truncate —— 删除字符串中从指定位置开始到结尾的全部字符
该方法是直接操作原来的字符串。无返回值。该方法 truncate() 方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
fn main() { let mut string_truncate = String::from("测试truncate"); string_truncate.truncate(3); dbg!(string_truncate); }
代码运行结果:
string_truncate = "测"
4、clear —— 清空字符串
该方法是直接操作原来的字符串。调用后,删除字符串中的所有字符,相当于 truncate() 方法参数为 0 的时候。
示例代码如下:
fn main() { let mut string_clear = String::from("string clear"); string_clear.clear(); dbg!(string_clear); }
代码运行结果:
string_clear = ""
连接 (Concatenate)
1、使用 + 或者 += 连接字符串
使用 + 或者 += 连接字符串,要求右边的参数必须为字符串的切片引用(Slice)类型。其实当调用 + 的操作符时,相当于调用了 std::string 标准库中的 add() 方法,这里 add() 方法的第二个参数是一个引用的类型。因此我们在使用 +, 必须传递切片引用类型。不能直接传递 String 类型。+ 和 += 都是返回一个新的字符串。所以变量声明可以不需要 mut 关键字修饰。
示例代码如下:
fn main() { let string_append = String::from("hello "); let string_rust = String::from("rust"); // &string_rust会自动解引用为&str let result = string_append + &string_rust; let mut result = result + "!"; result += "!!!"; println!("连接字符串 + -> {}", result); }
代码运行结果:
连接字符串 + -> hello rust!!!!
add() 方法的定义:
#![allow(unused)] fn main() { fn add(self, s: &str) -> String }
因为该方法涉及到更复杂的特征功能,因此我们这里简单说明下:
fn main() { let s1 = String::from("hello,"); let s2 = String::from("world!"); // 在下句中,s1的所有权被转移走了,因此后面不能再使用s1 let s3 = s1 + &s2; assert_eq!(s3,"hello,world!"); // 下面的语句如果去掉注释,就会报错 // println!("{}",s1); }
self 是 String 类型的字符串 s1,该函数说明,只能将 &str 类型的字符串切片添加到 String 类型的 s1 上,然后返回一个新的 String 类型,所以 let s3 = s1 + &s2; 就很好解释了,将 String 类型的 s1 与 &str 类型的 s2 进行相加,最终得到 String 类型的 s3。
由此可推,以下代码也是合法的:
#![allow(unused)] fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); // String = String + &str + &str + &str + &str let s = s1 + "-" + &s2 + "-" + &s3; }
String + &str返回一个 String,然后再继续跟一个 &str 进行 + 操作,返回一个 String 类型,不断循环,最终生成一个 s,也是 String 类型。
s1 这个变量通过调用 add() 方法后,所有权被转移到 add() 方法里面, add() 方法调用后就被释放了,同时 s1 也被释放了。再使用 s1 就会发生错误。这里涉及到所有权转移(Move)的相关知识。
2、使用 format! 连接字符串
format! 这种方式适用于 String 和 &str 。format! 的用法与 print! 的用法类似,详见格式化输出。
示例代码如下:
fn main() { let s1 = "hello"; let s2 = String::from("rust"); let s = format!("{} {}!", s1, s2); println!("{}", s); }
代码运行结果:
hello rust!
字符串转义
我们可以通过转义的方式 \ 输出 ASCII 和 Unicode 字符。
fn main() { // 通过 \ + 字符的十六进制表示,转义输出一个字符 let byte_escape = "I'm writing \x52\x75\x73\x74!"; println!("What are you doing\x3F (\\x3F means ?) {}", byte_escape); // \u 可以输出一个 unicode 字符 let unicode_codepoint = "\u{211D}"; let character_name = "\"DOUBLE-STRUCK CAPITAL R\""; println!( "Unicode character {} (U+211D) is called {}", unicode_codepoint, character_name ); // 换行了也会保持之前的字符串格式 let long_string = "String literals can span multiple lines. The linebreak and indentation here ->\ <- can be escaped too!"; println!("{}", long_string); }
当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:
fn main() { println!("{}", "hello \\x52\\x75\\x73\\x74"); let raw_str = r"Escapes don't work here: \x3F \u{211D}"; println!("{}", raw_str); // 如果字符串包含双引号,可以在开头和结尾加 # let quotes = r#"And then I said: "There is no escape!""#; println!("{}", quotes); // 如果还是有歧义,可以继续增加,没有限制 let longer_delimiter = r###"A string with "# in it. And even "##!"###; println!("{}", longer_delimiter); }
操作 UTF-8 字符串
前文提到了几种使用 UTF-8 字符串的方式,下面来一一说明。
字符
如果你想要以 Unicode 字符的方式遍历字符串,最好的办法是使用 chars 方法,例如:
#![allow(unused)] fn main() { for c in "中国人".chars() { println!("{}", c); } }
输出如下
中
国
人
字节
这种方式是返回字符串的底层字节数组表现形式:
#![allow(unused)] fn main() { for b in "中国人".bytes() { println!("{}", b); } }
输出如下:
228
184
173
229
155
189
228
186
186
获取子串
想要准确的从 UTF-8 字符串中获取子串是较为复杂的事情,例如想要从 holla中国人नमस्ते 这种变长的字符串中取出某一个子串,使用标准库你是做不到的。
你需要在 crates.io 上搜索 utf8 来寻找想要的功能。
可以考虑尝试下这个库:utf8_slice。
字符串深度剖析
那么问题来了,为啥 String 可变,而字符串字面值 str 却不可以?
就字符串字面值来说,我们在编译时就知道其内容,最终字面值文本被直接硬编码进可执行文件中,这使得字符串字面值快速且高效,这主要得益于字符串字面值的不可变性。不幸的是,我们不能为了获得这种性能,而把每一个在编译时大小未知的文本都放进内存中(你也做不到!),因为有的字符串是在程序运行得过程中动态生成的。
对于 String 类型,为了支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容,这些都是在程序运行时完成的:
- 首先向操作系统请求内存来存放
String对象 - 在使用完成后,将内存释放,归还给操作系统
其中第一部分由 String::from 完成,它创建了一个全新的 String。
重点来了,到了第二部分,就是百家齐放的环节,在有垃圾回收 GC 的语言中,GC 来负责标记并清除这些不再使用的内存对象,这个过程都是自动完成,无需开发者关心,非常简单好用;但是在无 GC 的语言中,需要开发者手动去释放这些内存对象,就像创建对象需要通过编写代码来完成一样,未能正确释放对象造成的后果简直不可估量。
对于 Rust 而言,安全和性能是写到骨子里的核心特性,如果使用 GC,那么会牺牲性能;如果使用手动管理内存,那么会牺牲安全,这该怎么办?为此,Rust 的开发者想出了一个无比惊艳的办法:变量在离开作用域后,就自动释放其占用的内存:
#![allow(unused)] fn main() { { let s = String::from("hello"); // 从此处起,s 是有效的 // 使用 s } // 此作用域已结束, // s 不再有效,内存被释放 }
与其它系统编程语言的 free 函数相同,Rust 也提供了一个释放内存的函数: drop,但是不同的是,其它语言要手动调用 free 来释放每一个变量占用的内存,而 Rust 则在变量离开作用域时,自动调用 drop 函数: 上面代码中,Rust 在结尾的 } 处自动调用 drop。
其实,在 C++ 中,也有这种概念: Resource Acquisition Is Initialization (RAII)。如果你使用过 RAII 模式的话应该对 Rust 的
drop函数并不陌生
这个模式对编写 Rust 代码的方式有着深远的影响,在后面章节我们会进行更深入的介绍。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
引用资料
- https://blog.csdn.net/a1595901624/article/details/119294443
机器学习
机器学习(Machine Learning,ML)是指从有限的观测数据中学习(或“猜测”)出具有一般性的规律,并利用这些规律对未知数据进行预测的方法。 机器学习方法可以粗略地分为三个基本要素:模型、学习准则、优化算法.
- 模型:根据经验来假设一个函数集合 ℱ ,称为假设空间(Hypothesis Space),然后通过观测其在训练集 𝒟 上的特性,从中选择一个理想的假设(Hypothesis)𝑓∗∈ ℱ.通常分为线性&非线性.
- 学习准则:
- 损失函数:
- 风险最小化准则:经验风险最小化(Empirical Risk Minimization,ERM)准则与结构风险最小化(Structure Risk Minimization,SRM)准则. 前者是要求你对训练集的拟合,后者是保证泛化能力.
- 损失函数:
- 优化算法:在确定了训练集 𝒟 、假设空间 ℱ 以及学习准则后,如何找到最优的模型 𝑓(𝒙,𝜃∗) 就成了一个最优化(Optimization)问题.机器学习的训练过程其实就是最优化问题的求解过程.
传统的机器学习主要关注如何学习一个预测模型.一般需要首先将数据表示为一组特征(Feature),特征的表示形式可以是连续的数值、离散的符号或其他形式.然后将这些特征输入到预测模型,并输出预测结果.这类机器学习可以看作浅层学习(Shallow Learning).浅层学习的一个重要特点是不涉及特征学习,其特征主要靠人工经验或特征转换方法来抽取. 在实际任务中使用机器学习模型一般会包含以下几个步骤(如图1.2所示):
- 数据预处理:经过数据的预处理,如去除噪声等.比如在文本分类中,去除停用词等.
- 特征提取:从原始数据中提取一些有效的特征.比如在图像分类中,提取边缘、尺度不变特征变换(Scale Invariant Feature Transform,SIFT)特征等.
- 特征转换:对特征进行一定的加工,比如降维和升维.降维包括特征抽取(Feature Extraction)和特征选择(Feature Selection)两种途径.常用的特征转换方法有主成分分析(Principal Components Analysis,PCA)、线性判别分析(Linear Discriminant Analysis,LDA)等.
- 预测:机器学习的核心部分,学习一个函数并进行预测.
表示学习:为了提高机器学习系统的准确率,我们就需要将输入信息转换为有效的特征,或者更一般性地称为表示(Representation).如果有一种算法可以自动地学习出有效的特征,并提高最终机器学习模型的性能,那么这种学习就可以叫作表示学习(Representation Learning). 语义鸿沟:表示学习的关键是解决语义鸿沟(Semantic Gap)问题.即车这一概念是高层语义特征,轿车、自行车、卡车是底层特征,如何同不同车种提取出那个高层特征“车”呢?如果一个预测模型直接建立在底层特征之上,会导致对预测模型的能力要求过高.如果可以有一个好的表示在某种程度上能够反映出数据的高层语义特征,那么我们就能相对容易地构建后续的机器学习模型。
一. 绪论
1.1 机器学习的定义
正如我们根据过去的经验来判断明天的天气,吃货们希望从购买经验中挑选一个好瓜,那能不能让计算机帮助人类来实现这个呢?机器学习正是这样的一门学科,人的“经验”对应计算机中的“数据”,让计算机来学习这些经验数据,生成一个算法模型,在面对新的情况中,计算机便能作出有效的判断,这便是机器学习。
另一本经典教材的作者Mitchell给出了一个形式化的定义,假设:
- P:计算机程序在某任务类T上的性能。
- T:计算机程序希望实现的任务类。
- E:表示经验,即历史的数据集。
若该计算机程序通过利用经验E在任务T上获得了性能P的改善,则称该程序对E进行了学习。
1.2 机器学习的一些基本术语
假设我们收集了一批西瓜的数据,例如:(色泽=青绿;根蒂=蜷缩;敲声=浊响), (色泽=乌黑;根蒂=稍蜷;敲声=沉闷), (色泽=浅自;根蒂=硬挺;敲声=清脆)……每对括号内是一个西瓜的记录,定义:
- 所有记录的集合为:数据集。
- 每一条记录为:一个实例(instance)或样本(sample)。
- 例如:色泽或敲声,单个的特点为特征(feature)或属性(attribute)。
- 对于一条记录,如果在坐标轴上表示,每个西瓜都可以用坐标轴中的一个点表示,一个点也是一个向量,例如(青绿,蜷缩,浊响),即每个西瓜为:一个特征向量(feature vector)。
- 一个样本的特征数为:维数(dimensionality),该西瓜的例子维数为3,当维数非常大时,也就是现在说的“维数灾难”。
计算机程序学习经验数据生成算法模型的过程中,每一条记录称为一个“训练样本”,同时在训练好模型后,我们希望使用新的样本来测试模型的效果,则每一个新的样本称为一个“测试样本”。定义:
- 所有训练样本的集合为:训练集(trainning set),[特殊]。
- 所有测试样本的集合为:测试集(test set),[一般]。
- 机器学习出来的模型适用于新样本的能力为:泛化能力(generalization),即从特殊到一般。
西瓜的例子中,我们是想计算机通过学习西瓜的特征数据,训练出一个决策模型,来判断一个新的西瓜是否是好瓜。可以得知我们预测的是:西瓜是好是坏,即好瓜与差瓜两种,是离散值。同样地,也有通过历年的人口数据,来预测未来的人口数量,人口数量则是连续值。定义:
- 预测值为离散值的问题为:分类(classification)。
- 预测值为连续值的问题为:回归(regression)。
我们预测西瓜是否是好瓜的过程中,很明显对于训练集中的西瓜,我们事先已经知道了该瓜是否是好瓜,学习器通过学习这些好瓜或差瓜的特征,从而总结出规律,即训练集中的西瓜我们都做了标记,称为标记信息。但也有没有标记信息的情形,例如:我们想将一堆西瓜根据特征分成两个小堆,使得某一堆的西瓜尽可能相似,即都是好瓜或差瓜,对于这种问题,我们事先并不知道西瓜的好坏,样本没有标记信息。定义:
- 训练数据有标记信息的学习任务为:监督学习(supervised learning),容易知道上面所描述的分类和回归都是监督学习的范畴。
- 训练数据没有标记信息的学习任务为:无监督学习(unsupervised learning),常见的有聚类和关联规则。
二. 模型的评估与选择
2.1 误差与过拟合
我们将学习器对样本的实际预测结果与样本的真实值之间的差异成为:误差(error)。定义:
- 在训练集上的误差称为训练误差(training error)或经验误差(empirical error)。
- 在测试集上的误差称为测试误差(test error)。
- 学习器在所有新样本上的误差称为泛化误差(generalization error)。
显然,我们希望得到的是在新样本上表现得很好的学习器,即泛化误差小的学习器。因此,我们应该让学习器尽可能地从训练集中学出普适性的“一般特征”,这样在遇到新样本时才能做出正确的判别。然而,当学习器把训练集学得“太好”的时候,即把一些训练样本的自身特点当做了普遍特征;同时也有学习能力不足的情况,即训练集的基本特征都没有学习出来。我们定义:
- 学习能力过强,以至于把训练样本所包含的不太一般的特性都学到了,称为:过拟合(overfitting)。
- 学习能太差,训练样本的一般性质尚未学好,称为:欠拟合(underfitting)。
可以得知:在过拟合问题中,训练误差十分小,但测试误差教大;在欠拟合问题中,训练误差和测试误差都比较大。目前,欠拟合问题比较容易克服,例如增加迭代次数等,但过拟合问题还没有十分好的解决方案,过拟合是机器学习面临的关键障碍。

2.2 评估方法
在现实任务中,我们往往有多种算法可供选择,那么我们应该选择哪一个算法才是最适合的呢?如上所述,我们希望得到的是泛化误差小的学习器,理想的解决方案是对模型的泛化误差进行评估,然后选择泛化误差最小的那个学习器。但是,泛化误差指的是模型在所有新样本上的适用能力,我们无法直接获得泛化误差。
因此,通常我们采用一个“测试集”来测试学习器对新样本的判别能力,然后以“测试集”上的“测试误差”作为“泛化误差”的近似。显然:我们选取的测试集应尽可能与训练集互斥,下面用一个小故事来解释why:
假设老师出了10 道习题供同学们练习,考试时老师又用同样的这10道题作为试题,可能有的童鞋只会做这10 道题却能得高分,很明显:这个考试成绩并不能有效地反映出真实水平。回到我们的问题上来,我们希望得到泛化性能好的模型,好比希望同学们课程学得好并获得了对所学知识"举一反三"的能力;训练样本相当于给同学们练习的习题,测试过程则相当于考试。显然,若测试样本被用作训练了,则得到的将是过于"乐观"的估计结果。
2.3 训练集与测试集的划分方法
如上所述:我们希望用一个“测试集”的“测试误差”来作为“泛化误差”的近似,因此我们需要对初始数据集进行有效划分,划分出互斥的“训练集”和“测试集”。下面介绍几种常用的划分方法:
2.3.1 留出法
将数据集D划分为两个互斥的集合,一个作为训练集S,一个作为测试集T,满足D=S∪T且S∩T=∅,常见的划分为:大约2/3-4/5的样本用作训练,剩下的用作测试。需要注意的是:训练/测试集的划分要尽可能保持数据分布的一致性,以避免由于分布的差异引入额外的偏差,常见的做法是采取分层抽样。同时,由于划分的随机性,单次的留出法结果往往不够稳定,一般要采用若干次随机划分,重复实验取平均值的做法。
2.3.2 交叉验证法
将数据集D划分为k个大小相同的互斥子集,满足D=D1∪D2∪...∪Dk,Di∩Dj=∅(i≠j),同样地尽可能保持数据分布的一致性,即采用分层抽样的方法获得这些子集。交叉验证法的思想是:每次用k-1个子集的并集作为训练集,余下的那个子集作为测试集,这样就有K种训练集/测试集划分的情况,从而可进行k次训练和测试,最终返回k次测试结果的均值。交叉验证法也称“k折交叉验证”,k最常用的取值是10,下图给出了10折交叉验证的示意图。

与留出法类似,将数据集D划分为K个子集的过程具有随机性,因此K折交叉验证通常也要重复p次,称为p次k折交叉验证,常见的是10次10折交叉验证,即进行了100次训练/测试。特殊地当划分的k个子集的每个子集中只有一个样本时,称为“留一法”,显然,留一法的评估结果比较准确,但对计算机的消耗也是巨大的。
2.3.3 自助法
我们希望评估的是用整个D训练出的模型。但在留出法和交叉验证法中,由于保留了一部分样本用于测试,因此实际评估的模型所使用的训练集比D小,这必然会引入一些因训练样本规模不同而导致的估计偏差。留一法受训练样本规模变化的影响较小,但计算复杂度又太高了。“自助法”正是解决了这样的问题。
自助法的基本思想是:给定包含m个样本的数据集D,每次随机从D 中挑选一个样本,将其拷贝放入D',然后再将该样本放回初始数据集D 中,使得该样本在下次采样时仍有可能被采到。重复执行m 次,就可以得到了包含m个样本的数据集D'。可以得知在m次采样中,样本始终不被采到的概率取极限为:

这样,通过自助采样,初始样本集D中大约有36.8%的样本没有出现在D'中,于是可以将D'作为训练集,D-D'作为测试集。自助法在数据集较小,难以有效划分训练集/测试集时很有用,但由于自助法产生的数据集(随机抽样)改变了初始数据集的分布,因此引入了估计偏差。在初始数据集足够时,留出法和交叉验证法更加常用。
2.4 调参
大多数学习算法都有些参数(parameter) 需要设定,参数配置不同,学得模型的性能往往有显著差别,这就是通常所说的"参数调节"或简称"调参" (parameter tuning)。
学习算法的很多参数是在实数范围内取值,因此,对每种参数取值都训练出模型来是不可行的。常用的做法是:对每个参数选定一个范围和步长λ,这样使得学习的过程变得可行。例如:假定算法有3 个参数,每个参数仅考虑5 个候选值,这样对每一组训练/测试集就有555= 125 个模型需考察,由此可见:拿下一个参数(即经验值)对于算法人员来说是有多么的happy。
最后需要注意的是:当选定好模型和调参完成后,我们需要使用初始的数据集D重新训练模型,即让最初划分出来用于评估的测试集也被模型学习,增强模型的学习效果。用上面考试的例子来比喻:就像高中时大家每次考试完,要将考卷的题目消化掉(大多数题目都还是之前没有见过的吧?),这样即使考差了也能开心的玩耍了~
2.5 性能度量
性能度量(performance measure)是衡量模型泛化能力的评价标准,在对比不同模型的能力时,使用不同的性能度量往往会导致不同的评判结果。本节除2.5.1外,其它主要介绍分类模型的性能度量。
2.5.1 最常见的性能度量
在回归任务中,即预测连续值的问题,最常用的性能度量是“均方误差”(mean squared error),很多的经典算法都是采用了MSE作为评价函数,想必大家都十分熟悉。

在分类任务中,即预测离散值的问题,最常用的是错误率和精度,错误率是分类错误的样本数占样本总数的比例,精度则是分类正确的样本数占样本总数的比例,易知:错误率+精度=1。


2.5.2 查准率/查全率/F1
错误率和精度虽然常用,但不能满足所有的需求,例如:在推荐系统中,我们只关心推送给用户的内容用户是否感兴趣(即查准率),或者说所有用户感兴趣的内容我们推送出来了多少(即查全率)。因此,使用查准/查全率更适合描述这类问题。对于二分类问题,分类结果混淆矩阵与查准/查全率定义如下:

初次接触时,FN与FP很难正确的理解,按照惯性思维容易把FN理解成:False->Negtive,即将错的预测为错的,这样FN和TN就反了,后来找到一张图,描述得很详细,为方便理解,把这张图也贴在了下边:

正如天下没有免费的午餐,查准率和查全率是一对矛盾的度量。例如我们想让推送的内容尽可能用户全都感兴趣,那只能推送我们把握高的内容,这样就漏掉了一些用户感兴趣的内容,查全率就低了;如果想让用户感兴趣的内容都被推送,那只有将所有内容都推送上,宁可错杀一千,不可放过一个,这样查准率就很低了。
“P-R曲线”正是描述查准/查全率变化的曲线,P-R曲线定义如下:根据学习器的预测结果(一般为一个实值或概率)对测试样本进行排序,将最可能是“正例”的样本排在前面,最不可能是“正例”的排在后面,按此顺序逐个把样本作为“正例”进行预测,每次计算出当前的P值和R值,如下图所示:

P-R曲线如何评估呢?若一个学习器A的P-R曲线被另一个学习器B的P-R曲线完全包住,则称:B的性能优于A。若A和B的曲线发生了交叉,则谁的曲线下的面积大,谁的性能更优。但一般来说,曲线下的面积是很难进行估算的,所以衍生出了“平衡点”(Break-Event Point,简称BEP),即当P=R时的取值,平衡点的取值越高,性能更优。
P和R指标有时会出现矛盾的情况,这样就需要综合考虑他们,最常见的方法就是F-Measure,又称F-Score。F-Measure是P和R的加权调和平均,即:


特别地,当β=1时,也就是常见的F1度量,是P和R的调和平均,当F1较高时,模型的性能越好。


有时候我们会有多个二分类混淆矩阵,例如:多次训练或者在多个数据集上训练,那么估算全局性能的方法有两种,分为宏观和微观。简单理解,宏观就是先算出每个混淆矩阵的P值和R值,然后取得平均P值macro-P和平均R值macro-R,在算出Fβ或F1,而微观则是计算出混淆矩阵的平均TP、FP、TN、FN,接着进行计算P、R,进而求出Fβ或F1。

2.5.3 ROC与AUC
如上所述:学习器对测试样本的评估结果一般为一个实值或概率,设定一个阈值,大于阈值为正例,小于阈值为负例,因此这个实值的好坏直接决定了学习器的泛化性能,若将这些实值排序,则排序的好坏决定了学习器的性能高低。ROC曲线正是从这个角度出发来研究学习器的泛化性能,ROC曲线与P-R曲线十分类似,都是按照排序的顺序逐一按照正例预测,不同的是ROC曲线以“真正例率”(True Positive Rate,简称TPR)为横轴,纵轴为“假正例率”(False Positive Rate,简称FPR),ROC偏重研究基于测试样本评估值的排序好坏。


简单分析图像,可以得知:当FN=0时,TN也必须0,反之也成立,我们可以画一个队列,试着使用不同的截断点(即阈值)去分割队列,来分析曲线的形状,(0,0)表示将所有的样本预测为负例,(1,1)则表示将所有的样本预测为正例,(0,1)表示正例全部出现在负例之前的理想情况,(1,0)则表示负例全部出现在正例之前的最差情况。限于篇幅,这里不再论述。
现实中的任务通常都是有限个测试样本,因此只能绘制出近似ROC曲线。绘制方法:首先根据测试样本的评估值对测试样本排序,接着按照以下规则进行绘制。

同样地,进行模型的性能比较时,若一个学习器A的ROC曲线被另一个学习器B的ROC曲线完全包住,则称B的性能优于A。若A和B的曲线发生了交叉,则谁的曲线下的面积大,谁的性能更优。ROC曲线下的面积定义为AUC(Area Uder ROC Curve),不同于P-R的是,这里的AUC是可估算的,即AOC曲线下每一个小矩形的面积之和。易知:AUC越大,证明排序的质量越好,AUC为1时,证明所有正例排在了负例的前面,AUC为0时,所有的负例排在了正例的前面。

2.5.4 代价敏感错误率与代价曲线
上面的方法中,将学习器的犯错同等对待,但在现实生活中,将正例预测成假例与将假例预测成正例的代价常常是不一样的,例如:将无疾病-->有疾病只是增多了检查,但有疾病-->无疾病却是增加了生命危险。以二分类为例,由此引入了“代价矩阵”(cost matrix)。

在非均等错误代价下,我们希望的是最小化“总体代价”,这样“代价敏感”的错误率(2.5.1节介绍)为:

同样对于ROC曲线,在非均等错误代价下,演变成了“代价曲线”,代价曲线横轴是取值在[0,1]之间的正例概率代价,式中p表示正例的概率,纵轴是取值为[0,1]的归一化代价。


代价曲线的绘制很简单:设ROC曲线上一点的坐标为(TPR,FPR) ,则可相应计算出FNR,然后在代价平面上绘制一条从(0,FPR) 到(1,FNR) 的线段,线段下的面积即表示了该条件下的期望总体代价;如此将ROC 曲线土的每个点转化为代价平面上的一条线段,然后取所有线段的下界,围成的面积即为在所有条件下学习器的期望总体代价,如图所示:

在此模型的性能度量方法就介绍完了,以前一直以为均方误差和精准度就可以了,现在才发现天空如此广阔~
2.6 比较检验
在比较学习器泛化性能的过程中,统计假设检验(hypothesis test)为学习器性能比较提供了重要依据,即若A在某测试集上的性能优于B,那A学习器比B好的把握有多大。 为方便论述,本篇中都是以“错误率”作为性能度量的标准。
2.6.1 假设检验
“假设”指的是对样本总体的分布或已知分布中某个参数值的一种猜想,例如:假设总体服从泊松分布,或假设正态总体的期望u=u0。回到本篇中,我们可以通过测试获得测试错误率,但直观上测试错误率和泛化错误率相差不会太远,因此可以通过测试错误率来推测泛化错误率的分布,这就是一种假设检验。



2.6.2 交叉验证t检验

2.6.3 McNemar检验
MaNemar主要用于二分类问题,与成对t检验一样也是用于比较两个学习器的性能大小。主要思想是:若两学习器的性能相同,则A预测正确B预测错误数应等于B预测错误A预测正确数,即e01=e10,且|e01-e10|服从N(1,e01+e10)分布。

因此,如下所示的变量服从自由度为1的卡方分布,即服从标准正态分布N(0,1)的随机变量的平方和,下式只有一个变量,故自由度为1,检验的方法同上:做出假设-->求出满足显著度的临界点-->给出拒绝域-->验证假设。

2.6.4 Friedman检验与Nemenyi后续检验
上述的三种检验都只能在一组数据集上,F检验则可以在多组数据集进行多个学习器性能的比较,基本思想是在同一组数据集上,根据测试结果(例:测试错误率)对学习器的性能进行排序,赋予序值1,2,3...,相同则平分序值,如下图所示:

若学习器的性能相同,则它们的平均序值应该相同,且第i个算法的平均序值ri服从正态分布N((k+1)/2,(k+1)(k-1)/12),则有:


服从自由度为k-1和(k-1)(N-1)的F分布。下面是F检验常用的临界值:

若“H0:所有算法的性能相同”这个假设被拒绝,则需要进行后续检验,来得到具体的算法之间的差异。常用的就是Nemenyi后续检验。Nemenyi检验计算出平均序值差别的临界值域,下表是常用的qa值,若两个算法的平均序值差超出了临界值域CD,则相应的置信度1-α拒绝“两个算法性能相同”的假设。


2.7 偏差与方差
偏差-方差分解是解释学习器泛化性能的重要工具。在学习算法中,偏差指的是预测的期望值与真实值的偏差,方差则是每一次预测值与预测值得期望之间的差均方。实际上,偏差体现了学习器预测的准确度,而方差体现了学习器预测的稳定性。通过对泛化误差的进行分解,可以得到:
- 期望泛化误差=方差+偏差
- 偏差刻画学习器的拟合能力
- 方差体现学习器的稳定性
易知:方差和偏差具有矛盾性,这就是常说的偏差-方差窘境(bias-variance dilamma),随着训练程度的提升,期望预测值与真实值之间的差异越来越小,即偏差越来越小,但是另一方面,随着训练程度加大,学习算法对数据集的波动越来越敏感,方差值越来越大。换句话说:在欠拟合时,偏差主导泛化误差,而训练到一定程度后,偏差越来越小,方差主导了泛化误差。因此训练也不要贪杯,适度辄止。

三. 线性模型
谈及线性模型,其实我们很早就已经与它打过交道,还记得高中数学必修3课本中那个顽皮的“最小二乘法”吗?这就是线性模型的经典算法之一:根据给定的(x,y)点对,求出一条与这些点拟合效果最好的直线y=ax+b,之前我们利用下面的公式便可以计算出拟合直线的系数a,b(3.1中给出了具体的计算过程),从而对于一个新的x,可以预测它所对应的y值。前面我们提到:在机器学习的术语中,当预测值为连续值时,称为“回归问题”,离散值时为“分类问题”。本篇先从线性回归任务开始,接着讨论分类和多分类问题。

3.1 线性回归
线性回归问题就是试图学到一个线性模型尽可能准确地预测新样本的输出值,例如:通过历年的人口数据预测2017年人口数量。在这类问题中,往往我们会先得到一系列的有标记数据,例如:2000-->13亿...2016-->15亿,这时输入的属性只有一个,即年份;也有输入多属性的情形,假设我们预测一个人的收入,这时输入的属性值就不止一个了,例如:(学历,年龄,性别,颜值,身高,体重)-->15k。
有时这些输入的属性值并不能直接被我们的学习模型所用,需要进行相应的处理,对于连续值的属性,一般都可以被学习器所用,有时会根据具体的情形作相应的预处理,例如:归一化等;对于离散值的属性,可作下面的处理:
-
若属性值之间存在“序关系”,则可以将其转化为连续值,例如:身高属性分为“高”“中等”“矮”,可转化为数值:{1, 0.5, 0}。
-
若属性值之间不存在“序关系”,则通常将其转化为向量的形式,例如:性别属性分为“男”“女”,可转化为二维向量:{(1,0),(0,1)}。
(1)当输入属性只有一个的时候,就是最简单的情形,也就是我们高中时最熟悉的“最小二乘法”(Euclidean distance),首先计算出每个样本预测值与真实值之间的误差并求和,通过最小化均方误差MSE,使用求偏导等于零的方法计算出拟合直线y=wx+b的两个参数w和b,计算过程如下图所示:

(2)当输入属性有多个的时候,例如对于一个样本有d个属性{(x1,x2...xd),y},则y=wx+b需要写成:

通常对于多元问题,常常使用矩阵的形式来表示数据。在本问题中,将具有m个样本的数据集表示成矩阵X,将系数w与b合并成一个列向量,这样每个样本的预测值以及所有样本的均方误差最小化就可以写成下面的形式:



同样地,我们使用最小二乘法对w和b进行估计,令均方误差的求导等于0,需要注意的是,当一个矩阵的行列式不等于0时,我们才可能对其求逆,因此对于下式,我们需要考虑矩阵(X的转置*X)的行列式是否为0,若不为0,则可以求出其解,若为0,则需要使用其它的方法进行计算,书中提到了引入正则化,此处不进行深入。

另一方面,有时像上面这种原始的线性回归可能并不能满足需求,例如:y值并不是线性变化,而是在指数尺度上变化。这时我们可以采用线性模型来逼近y的衍生物,例如lny,这时衍生的线性模型如下所示,实际上就是相当于将指数曲线投影在一条直线上,如下图所示:

更一般地,考虑所有y的衍生物的情形,就得到了“广义的线性模型”(generalized linear model),其中,g(*)称为联系函数(link function)。

3.2 线性几率回归
回归就是通过输入的属性值得到一个预测值,利用上述广义线性模型的特征,是否可以通过一个联系函数,将预测值转化为离散值从而进行分类呢?线性几率回归正是研究这样的问题。对数几率引入了一个对数几率函数(logistic function),将预测值投影到0-1之间,从而将线性回归问题转化为二分类问题。


若将y看做样本为正例的概率,(1-y)看做样本为反例的概率,则上式实际上使用线性回归模型的预测结果器逼近真实标记的对数几率。因此这个模型称为“对数几率回归”(logistic regression),也有一些书籍称之为“逻辑回归”。下面使用最大似然估计的方法来计算出w和b两个参数的取值,下面只列出求解的思路,不列出具体的计算过程。


3.3 线性判别分析
线性判别分析(Linear Discriminant Analysis,简称LDA),其基本思想是:将训练样本投影到一条直线上,使得同类的样例尽可能近,不同类的样例尽可能远。如图所示:


想让同类样本点的投影点尽可能接近,不同类样本点投影之间尽可能远,即:让各类的协方差之和尽可能小,不用类之间中心的距离尽可能大。基于这样的考虑,LDA定义了两个散度矩阵。
- 类内散度矩阵(within-class scatter matrix)

- 类间散度矩阵(between-class scaltter matrix)

因此得到了LDA的最大化目标:“广义瑞利商”(generalized Rayleigh quotient)。

从而分类问题转化为最优化求解w的问题,当求解出w后,对新的样本进行分类时,只需将该样本点投影到这条直线上,根据与各个类别的中心值进行比较,从而判定出新样本与哪个类别距离最近。求解w的方法如下所示,使用的方法为λ乘子。

若将w看做一个投影矩阵,类似PCA的思想,则LDA可将样本投影到N-1维空间(N为类簇数),投影的过程使用了类别信息(标记信息),因此LDA也常被视为一种经典的监督降维技术。
3.4 多分类学习
现实中我们经常遇到不只两个类别的分类问题,即多分类问题,在这种情形下,我们常常运用“拆分”的策略,通过多个二分类学习器来解决多分类问题,即将多分类问题拆解为多个二分类问题,训练出多个二分类学习器,最后将多个分类结果进行集成得出结论。最为经典的拆分策略有三种:“一对一”(OvO)、“一对其余”(OvR)和“多对多”(MvM),核心思想与示意图如下所示。
-
OvO:给定数据集D,假定其中有N个真实类别,将这N个类别进行两两配对(一个正类/一个反类),从而产生N(N-1)/2个二分类学习器,在测试阶段,将新样本放入所有的二分类学习器中测试,得出N(N-1)个结果,最终通过投票产生最终的分类结果。
-
OvM:给定数据集D,假定其中有N个真实类别,每次取出一个类作为正类,剩余的所有类别作为一个新的反类,从而产生N个二分类学习器,在测试阶段,得出N个结果,若仅有一个学习器预测为正类,则对应的类标作为最终分类结果。
-
MvM:给定数据集D,假定其中有N个真实类别,每次取若干个类作为正类,若干个类作为反类(通过ECOC码给出,编码),若进行了M次划分,则生成了M个二分类学习器,在测试阶段(解码),得出M个结果组成一个新的码,最终通过计算海明/欧式距离选择距离最小的类别作为最终分类结果。


3.5 类别不平衡问题
类别不平衡(class-imbanlance)就是指分类问题中不同类别的训练样本相差悬殊的情况,例如正例有900个,而反例只有100个,这个时候我们就需要进行相应的处理来平衡这个问题。常见的做法有三种:
- 在训练样本较多的类别中进行“欠采样”(undersampling),比如从正例中采出100个,常见的算法有:EasyEnsemble。
- 在训练样本较少的类别中进行“过采样”(oversampling),例如通过对反例中的数据进行插值,来产生额外的反例,常见的算法有SMOTE。
- 直接基于原数据集进行学习,对预测值进行“再缩放”处理。其中再缩放也是代价敏感学习的基础。

四. 决策树
4.1 决策树基本概念
顾名思义,决策树是基于树结构来进行决策的,在网上看到一个例子十分有趣,放在这里正好合适。现想象一位捉急的母亲想要给自己的女娃介绍一个男朋友,于是有了下面的对话:
女儿:多大年纪了?
母亲:26。
女儿:长的帅不帅?
母亲:挺帅的。
女儿:收入高不?
母亲:不算很高,中等情况。
女儿:是公务员不?
母亲:是,在税务局上班呢。
女儿:那好,我去见见。
这个女孩的挑剔过程就是一个典型的决策树,即相当于通过年龄、长相、收入和是否公务员将男童鞋分为两个类别:见和不见。假设这个女孩对男人的要求是:30岁以下、长相中等以上并且是高收入者或中等以上收入的公务员,那么使用下图就能很好地表示女孩的决策逻辑(即一颗决策树)。

在上图的决策树中,决策过程的每一次判定都是对某一属性的“测试”,决策最终结论则对应最终的判定结果。一般一颗决策树包含:一个根节点、若干个内部节点和若干个叶子节点,易知:
- 每个非叶节点表示一个特征属性测试。
- 每个分支代表这个特征属性在某个值域上的输出。
- 每个叶子节点存放一个类别。
- 每个节点包含的样本集合通过属性测试被划分到子节点中,根节点包含样本全集。
4.2 决策树的构造
决策树的构造是一个递归的过程,有三种情形会导致递归返回:(1) 当前结点包含的样本全属于同一类别,这时直接将该节点标记为叶节点,并设为相应的类别;(2) 当前属性集为空,或是所有样本在所有属性上取值相同,无法划分,这时将该节点标记为叶节点,并将其类别设为该节点所含样本最多的类别;(3) 当前结点包含的样本集合为空,不能划分,这时也将该节点标记为叶节点,并将其类别设为父节点中所含样本最多的类别。算法的基本流程如下图所示:

可以看出:决策树学习的关键在于如何选择划分属性,不同的划分属性得出不同的分支结构,从而影响整颗决策树的性能。属性划分的目标是让各个划分出来的子节点尽可能地“纯”,即属于同一类别。因此下面便是介绍量化纯度的具体方法,决策树最常用的算法有三种:ID3,C4.5和CART。
4.2.1 ID3算法
ID3算法使用信息增益为准则来选择划分属性,“信息熵”(information entropy)是度量样本结合纯度的常用指标,假定当前样本集合D中第k类样本所占比例为pk,则样本集合D的信息熵定义为:

假定通过属性划分样本集D,产生了V个分支节点,v表示其中第v个分支节点,易知:分支节点包含的样本数越多,表示该分支节点的影响力越大。故可以计算出划分后相比原始数据集D获得的“信息增益”(information gain)。

信息增益越大,表示使用该属性划分样本集D的效果越好,因此ID3算法在递归过程中,每次选择最大信息增益的属性作为当前的划分属性。
4.2.2 C4.5算法
ID3算法存在一个问题,就是偏向于取值数目较多的属性,例如:如果存在一个唯一标识,这样样本集D将会被划分为|D|个分支,每个分支只有一个样本,这样划分后的信息熵为零,十分纯净,但是对分类毫无用处。因此C4.5算法使用了“增益率”(gain ratio)来选择划分属性,来避免这个问题带来的困扰。首先使用ID3算法计算出信息增益高于平均水平的候选属性,接着C4.5计算这些候选属性的增益率,增益率定义为:

4.2.3 CART算法
CART决策树使用“基尼指数”(Gini index)来选择划分属性,基尼指数反映的是从样本集D中随机抽取两个样本,其类别标记不一致的概率,因此Gini(D)越小越好,基尼指数定义如下:

进而,使用属性α划分后的基尼指数为:

4.3 剪枝处理
从决策树的构造流程中我们可以直观地看出:不管怎么样的训练集,决策树总是能很好地将各个类别分离开来,这时就会遇到之前提到过的问题:过拟合(overfitting),即太依赖于训练样本。剪枝(pruning)则是决策树算法对付过拟合的主要手段,剪枝的策略有两种如下:
- 预剪枝(prepruning):在构造的过程中先评估,再考虑是否分支。
- 后剪枝(post-pruning):在构造好一颗完整的决策树后,自底向上,评估分支的必要性。
评估指的是性能度量,即决策树的泛化性能。之前提到:可以使用测试集作为学习器泛化性能的近似,因此可以将数据集划分为训练集和测试集。预剪枝表示在构造数的过程中,对一个节点考虑是否分支时,首先计算决策树不分支时在测试集上的性能,再计算分支之后的性能,若分支对性能没有提升,则选择不分支(即剪枝)。后剪枝则表示在构造好一颗完整的决策树后,从最下面的节点开始,考虑该节点分支对模型的性能是否有提升,若无则剪枝,即将该节点标记为叶子节点,类别标记为其包含样本最多的类别。



上图分别表示不剪枝处理的决策树、预剪枝决策树和后剪枝决策树。预剪枝处理使得决策树的很多分支被剪掉,因此大大降低了训练时间开销,同时降低了过拟合的风险,但另一方面由于剪枝同时剪掉了当前节点后续子节点的分支,因此预剪枝“贪心”的本质阻止了分支的展开,在一定程度上带来了欠拟合的风险。而后剪枝则通常保留了更多的分支,因此采用后剪枝策略的决策树性能往往优于预剪枝,但其自底向上遍历了所有节点,并计算性能,训练时间开销相比预剪枝大大提升。
4.4 连续值与缺失值处理
对于连续值的属性,若每个取值作为一个分支则显得不可行,因此需要进行离散化处理,常用的方法为二分法,基本思想为:给定样本集D与连续属性α,二分法试图找到一个划分点t将样本集D在属性α上分为≤t与>t。
- 首先将α的所有取值按升序排列,所有相邻属性的均值作为候选划分点(n-1个,n为α所有的取值数目)。
- 计算每一个划分点划分集合D(即划分为两个分支)后的信息增益。
- 选择最大信息增益的划分点作为最优划分点。

现实中常会遇到不完整的样本,即某些属性值缺失。有时若简单采取剔除,则会造成大量的信息浪费,因此在属性值缺失的情况下需要解决两个问题:(1)如何选择划分属性。(2)给定划分属性,若某样本在该属性上缺失值,如何划分到具体的分支上。假定为样本集中的每一个样本都赋予一个权重,根节点中的权重初始化为1,则定义:

对于(1):通过在样本集D中选取在属性α上没有缺失值的样本子集,计算在该样本子集上的信息增益,最终的信息增益等于该样本子集划分后信息增益乘以样本子集占样本集的比重。即:

对于(2):若该样本子集在属性α上的值缺失,则将该样本以不同的权重(即每个分支所含样本比例)划入到所有分支节点中。该样本在分支节点中的权重变为:

五. 神经网络
在机器学习中,神经网络一般指的是“神经网络学习”,是机器学习与神经网络两个学科的交叉部分。所谓神经网络,目前用得最广泛的一个定义是“神经网络是由具有适应性的简单单元组成的广泛并行互连的网络,它的组织能够模拟生物神经系统对真实世界物体所做出的交互反应”。
5.1 神经元模型
神经网络中最基本的单元是神经元模型(neuron)。在生物神经网络的原始机制中,每个神经元通常都有多个树突(dendrite),一个轴突(axon)和一个细胞体(cell body),树突短而多分支,轴突长而只有一个;在功能上,树突用于传入其它神经元传递的神经冲动,而轴突用于将神经冲动传出到其它神经元,当树突或细胞体传入的神经冲动使得神经元兴奋时,该神经元就会通过轴突向其它神经元传递兴奋。神经元的生物学结构如下图所示,不得不说高中的生化知识大学忘得可是真干净...

一直沿用至今的“M-P神经元模型”正是对这一结构进行了抽象,也称“阈值逻辑单元“,其中树突对应于输入部分,每个神经元收到n个其他神经元传递过来的输入信号,这些信号通过带权重的连接传递给细胞体,这些权重又称为连接权(connection weight)。细胞体分为两部分,前一部分计算总输入值(即输入信号的加权和,或者说累积电平),后一部分先计算总输入值与该神经元阈值的差值,然后通过激活函数(activation function)的处理,产生输出从轴突传送给其它神经元。M-P神经元模型如下图所示:

与线性分类十分相似,神经元模型最理想的激活函数也是阶跃函数,即将神经元输入值与阈值的差值映射为输出值1或0,若差值大于零输出1,对应兴奋;若差值小于零则输出0,对应抑制。但阶跃函数不连续,不光滑,故在M-P神经元模型中,也采用Sigmoid函数来近似, Sigmoid函数将较大范围内变化的输入值挤压到 (0,1) 输出值范围内,所以也称为挤压函数(squashing function)。

将多个神经元按一定的层次结构连接起来,就得到了神经网络。它是一种包含多个参数的模型,比方说10个神经元两两连接,则有100个参数需要学习(每个神经元有9个连接权以及1个阈值),若将每个神经元都看作一个函数,则整个神经网络就是由这些函数相互嵌套而成。
5.2 感知机与多层网络
感知机(Perceptron)是由两层神经元组成的一个简单模型,但只有输出层是M-P神经元,即只有输出层神经元进行激活函数处理,也称为功能神经元(functional neuron);输入层只是接受外界信号(样本属性)并传递给输出层(输入层的神经元个数等于样本的属性数目),而没有激活函数。这样一来,感知机与之前线性模型中的对数几率回归的思想基本是一样的,都是通过对属性加权与另一个常数求和,再使用sigmoid函数将这个输出值压缩到0-1之间,从而解决分类问题。不同的是感知机的输出层应该可以有多个神经元,从而可以实现多分类问题,同时两个模型所用的参数估计方法十分不同。
给定训练集,则感知机的n+1个参数(n个权重+1个阈值)都可以通过学习得到。阈值Θ可以看作一个输入值固定为-1的哑结点的权重ωn+1,即假设有一个固定输入xn+1=-1的输入层神经元,其对应的权重为ωn+1,这样就把权重和阈值统一为权重的学习了。简单感知机的结构如下图所示:

感知机权重的学习规则如下:对于训练样本(x,y),当该样本进入感知机学习后,会产生一个输出值,若该输出值与样本的真实标记不一致,则感知机会对权重进行调整,若激活函数为阶跃函数,则调整的方法为(基于梯度下降法):

其中 η∈(0,1)称为学习率,可以看出感知机是通过逐个样本输入来更新权重,首先设定好初始权重(一般为随机),逐个地输入样本数据,若输出值与真实标记相同则继续输入下一个样本,若不一致则更新权重,然后再重新逐个检验,直到每个样本数据的输出值都与真实标记相同。容易看出:感知机模型总是能将训练数据的每一个样本都预测正确,和决策树模型总是能将所有训练数据都分开一样,感知机模型很容易产生过拟合问题。
由于感知机模型只有一层功能神经元,因此其功能十分有限,只能处理线性可分的问题,对于这类问题,感知机的学习过程一定会收敛(converge),因此总是可以求出适当的权值。但是对于像书上提到的异或问题,只通过一层功能神经元往往不能解决,因此要解决非线性可分问题,需要考虑使用多层功能神经元,即神经网络。多层神经网络的拓扑结构如下图所示:

在神经网络中,输入层与输出层之间的层称为隐含层或隐层(hidden layer),隐层和输出层的神经元都是具有激活函数的功能神经元。只需包含一个隐层便可以称为多层神经网络,常用的神经网络称为“多层前馈神经网络”(multi-layer feedforward neural network),该结构满足以下几个特点:
- 每层神经元与下一层神经元之间完全互连
- 神经元之间不存在同层连接
- 神经元之间不存在跨层连接

根据上面的特点可以得知:这里的“前馈”指的是网络拓扑结构中不存在环或回路,而不是指该网络只能向前传播而不能向后传播(下节中的BP神经网络正是基于前馈神经网络而增加了反馈调节机制)。神经网络的学习过程就是根据训练数据来调整神经元之间的“连接权”以及每个神经元的阈值,换句话说:神经网络所学习到的东西都蕴含在网络的连接权与阈值中。
5.3 BP神经网络算法
由上面可以得知:神经网络的学习主要蕴含在权重和阈值中,多层网络使用上面简单感知机的权重调整规则显然不够用了,BP神经网络算法即误差逆传播算法(error BackPropagation)正是为学习多层前馈神经网络而设计,BP神经网络算法是迄今为止最成功的的神经网络学习算法。
一般而言,只需包含一个足够多神经元的隐层,就能以任意精度逼近任意复杂度的连续函数[Hornik et al.,1989],故下面以训练单隐层的前馈神经网络为例,介绍BP神经网络的算法思想。

上图为一个单隐层前馈神经网络的拓扑结构,BP神经网络算法也使用梯度下降法(gradient descent),以单个样本的均方误差的负梯度方向对权重进行调节。可以看出:BP算法首先将误差反向传播给隐层神经元,调节隐层到输出层的连接权重与输出层神经元的阈值;接着根据隐含层神经元的均方误差,来调节输入层到隐含层的连接权值与隐含层神经元的阈值。BP算法基本的推导过程与感知机的推导过程原理是相同的,下面给出调整隐含层到输出层的权重调整规则的推导过程:

学习率η∈(0,1)控制着沿反梯度方向下降的步长,若步长太大则下降太快容易产生震荡,若步长太小则收敛速度太慢,一般地常把η设置为0.1,有时更新权重时会将输出层与隐含层设置为不同的学习率。BP算法的基本流程如下所示:

BP算法的更新规则是基于每个样本的预测值与真实类标的均方误差来进行权值调节,即BP算法每次更新只针对于单个样例。需要注意的是:BP算法的最终目标是要最小化整个训练集D上的累积误差,即:

如果基于累积误差最小化的更新规则,则得到了累积误差逆传播算法(accumulated error backpropagation),即每次读取全部的数据集一遍,进行一轮学习,从而基于当前的累积误差进行权值调整,因此参数更新的频率相比标准BP算法低了很多,但在很多任务中,尤其是在数据量很大的时候,往往标准BP算法会获得较好的结果。另外对于如何设置隐层神经元个数的问题,至今仍然没有好的解决方案,常使用“试错法”进行调整。
前面提到,BP神经网络强大的学习能力常常容易造成过拟合问题,有以下两种策略来缓解BP网络的过拟合问题:
- 早停:将数据分为训练集与测试集,训练集用于学习,测试集用于评估性能,若在训练过程中,训练集的累积误差降低,而测试集的累积误差升高,则停止训练。
- 引入正则化(regularization):基本思想是在累积误差函数中增加一个用于描述网络复杂度的部分,例如所有权值与阈值的平方和,其中λ∈(0,1)用于对累积经验误差与网络复杂度这两项进行折中,常通过交叉验证法来估计。

5.4 全局最小与局部最小
模型学习的过程实质上就是一个寻找最优参数的过程,例如BP算法试图通过最速下降来寻找使得累积经验误差最小的权值与阈值,在谈到最优时,一般会提到局部极小(local minimum)和全局最小(global minimum)。
- 局部极小解:参数空间中的某个点,其邻域点的误差函数值均不小于该点的误差函数值。
- 全局最小解:参数空间中的某个点,所有其他点的误差函数值均不小于该点的误差函数值。

要成为局部极小点,只要满足该点在参数空间中的梯度为零。局部极小可以有多个,而全局最小只有一个。全局最小一定是局部极小,但局部最小却不一定是全局最小。显然在很多机器学习算法中,都试图找到目标函数的全局最小。梯度下降法的主要思想就是沿着负梯度方向去搜索最优解,负梯度方向是函数值下降最快的方向,若迭代到某处的梯度为0,则表示达到一个局部最小,参数更新停止。因此在现实任务中,通常使用以下策略尽可能地去接近全局最小。
- 以多组不同参数值初始化多个神经网络,按标准方法训练,迭代停止后,取其中误差最小的解作为最终参数。
- 使用“模拟退火”技术,这里不做具体介绍。
- 使用随机梯度下降,即在计算梯度时加入了随机因素,使得在局部最小时,计算的梯度仍可能不为0,从而迭代可以继续进行。
5.5 深度学习
理论上,参数越多,模型复杂度就越高,容量(capability)就越大,从而能完成更复杂的学习任务。深度学习(deep learning)正是一种极其复杂而强大的模型。
怎么增大模型复杂度呢?两个办法,一是增加隐层的数目,二是增加隐层神经元的数目。前者更有效一些,因为它不仅增加了功能神经元的数量,还增加了激活函数嵌套的层数。但是对于多隐层神经网络,经典算法如标准BP算法往往会在误差逆传播时发散(diverge),无法收敛达到稳定状态。
那要怎么有效地训练多隐层神经网络呢?一般来说有以下两种方法:
-
无监督逐层训练(unsupervised layer-wise training):每次训练一层隐节点,把上一层隐节点的输出当作输入来训练,本层隐结点训练好后,输出再作为下一层的输入来训练,这称为预训练(pre-training)。全部预训练完成后,再对整个网络进行微调(fine-tuning)训练。一个典型例子就是深度信念网络(deep belief network,简称DBN)。这种做法其实可以视为把大量的参数进行分组,先找出每组较好的设置,再基于这些局部最优的结果来训练全局最优。
-
权共享(weight sharing):令同一层神经元使用完全相同的连接权,典型的例子是卷积神经网络(Convolutional Neural Network,简称CNN)。这样做可以大大减少需要训练的参数数目。

深度学习可以理解为一种特征学习(feature learning)或者表示学习(representation learning),无论是DBN还是CNN,都是通过多个隐层来把与输出目标联系不大的初始输入转化为与输出目标更加密切的表示,使原来只通过单层映射难以完成的任务变为可能。即通过多层处理,逐渐将初始的“低层”特征表示转化为“高层”特征表示,从而使得最后可以用简单的模型来完成复杂的学习任务。
传统任务中,样本的特征需要人类专家来设计,这称为特征工程(feature engineering)。特征好坏对泛化性能有至关重要的影响。而深度学习为全自动数据分析带来了可能,可以自动产生更好的特征。
六. 支持向量机
支持向量机是一种经典的二分类模型,基本模型定义为特征空间中最大间隔的线性分类器,其学习的优化目标便是间隔最大化,因此支持向量机本身可以转化为一个凸二次规划求解的问题。
6.1 函数间隔与几何间隔
对于二分类学习,假设现在的数据是线性可分的,这时分类学习最基本的想法就是找到一个合适的超平面,该超平面能够将不同类别的样本分开,类似二维平面使用ax+by+c=0来表示,超平面实际上表示的就是高维的平面,如下图所示:

对数据点进行划分时,易知:当超平面距离与它最近的数据点的间隔越大,分类的鲁棒性越好,即当新的数据点加入时,超平面对这些点的适应性最强,出错的可能性最小。因此需要让所选择的超平面能够最大化这个间隔Gap(如下图所示), 常用的间隔定义有两种,一种称之为函数间隔,一种为几何间隔,下面将分别介绍这两种间隔,并对SVM为什么会选用几何间隔做了一些阐述。

6.1.1 函数间隔
在超平面w'x+b=0确定的情况下,|w'x*+b|能够代表点x距离超平面的远近,易知:当w'x+b>0时,表示x在超平面的一侧(正类,类标为1),而当w'x+b<0时,则表示x在超平面的另外一侧(负类,类别为-1),因此(w'x+b)y* 的正负性恰能表示数据点x*是否被分类正确。于是便引出了函数间隔的定义(functional margin):

而超平面(w,b)关于所有样本点(Xi,Yi)的函数间隔最小值则为超平面在训练数据集T上的函数间隔:

可以看出:这样定义的函数间隔在处理SVM上会有问题,当超平面的两个参数w和b同比例改变时,函数间隔也会跟着改变,但是实际上超平面还是原来的超平面,并没有变化。例如:w1x1+w2x2+w3x3+b=0其实等价于2w1x1+2w2x2+2w3x3+2b=0,但计算的函数间隔却翻了一倍。从而引出了能真正度量点到超平面距离的概念--几何间隔(geometrical margin)。
6.1.2 几何间隔
几何间隔代表的则是数据点到超平面的真实距离,对于超平面w'x+b=0,w代表的是该超平面的法向量,设x为超平面外一点x在法向量w方向上的投影点,x与超平面的距离为r,则有x=x-r(w/||w||),又x在超平面上,即w'x+b=0,代入即可得:

为了得到r的绝对值,令r呈上其对应的类别y,即可得到几何间隔的定义:

从上述函数间隔与几何间隔的定义可以看出:实质上函数间隔就是|w'x+b|,而几何间隔就是点到超平面的距离。
6.2 最大间隔与支持向量
通过前面的分析可知:函数间隔不适合用来最大化间隔,因此这里我们要找的最大间隔指的是几何间隔,于是最大间隔分类器的目标函数定义为:

一般地,我们令r^为1(这样做的目的是为了方便推导和目标函数的优化),从而上述目标函数转化为:

对于y(w'x+b)=1的数据点,即下图中位于w'x+b=1或w'x+b=-1上的数据点,我们称之为支持向量(support vector),易知:对于所有的支持向量,它们恰好满足y*(w'x*+b)=1,而所有不是支持向量的点,有y*(w'x*+b)>1。

6.3 从原始优化问题到对偶问题
对于上述得到的目标函数,求1/||w||的最大值相当于求||w||^2的最小值,因此很容易将原来的目标函数转化为:

即变为了一个带约束的凸二次规划问题,按书上所说可以使用现成的优化计算包(QP优化包)求解,但由于SVM的特殊性,一般我们将原问题变换为它的对偶问题,接着再对其对偶问题进行求解。为什么通过对偶问题进行求解,有下面两个原因:
- 一是因为使用对偶问题更容易求解;
- 二是因为通过对偶问题求解出现了向量内积的形式,从而能更加自然地引出核函数。
对偶问题,顾名思义,可以理解成优化等价的问题,更一般地,是将一个原始目标函数的最小化转化为它的对偶函数最大化的问题。对于当前的优化问题,首先我们写出它的朗格朗日函数:

上式很容易验证:当其中有一个约束条件不满足时,L的最大值为 ∞(只需令其对应的α为 ∞即可);当所有约束条件都满足时,L的最大值为1/2||w||^2(此时令所有的α为0),因此实际上原问题等价于:

由于这个的求解问题不好做,因此一般我们将最小和最大的位置交换一下(需满足KKT条件) ,变成原问题的对偶问题:

这样就将原问题的求最小变成了对偶问题求最大(用对偶这个词还是很形象),接下来便可以先求L对w和b的极小,再求L对α的极大。
(1)首先求L对w和b的极小,分别求L关于w和b的偏导,可以得出:

将上述结果代入L得到:

(2)接着L关于α极大求解α(通过SMO算法求解,此处不做深入)。

(3)最后便可以根据求解出的α,计算出w和b,从而得到分类超平面函数。

在对新的点进行预测时,实际上就是将数据点x*代入分类函数f(x)=w'x+b中,若f(x)>0,则为正类,f(x)<0,则为负类,根据前面推导得出的w与b,分类函数如下所示,此时便出现了上面所提到的内积形式。

这里实际上只需计算新样本与支持向量的内积,因为对于非支持向量的数据点,其对应的拉格朗日乘子一定为0,根据最优化理论(K-T条件),对于不等式约束y(w'x+b)-1≥0,满足:

6.4 核函数
由于上述的超平面只能解决线性可分的问题,对于线性不可分的问题,例如:异或问题,我们需要使用核函数将其进行推广。一般地,解决线性不可分问题时,常常采用映射的方式,将低维原始空间映射到高维特征空间,使得数据集在高维空间中变得线性可分,从而再使用线性学习器分类。如果原始空间为有限维,即属性数有限,那么总是存在一个高维特征空间使得样本线性可分。若∅代表一个映射,则在特征空间中的划分函数变为:

按照同样的方法,先写出新目标函数的拉格朗日函数,接着写出其对偶问题,求L关于w和b的极大,最后运用SOM求解α。可以得出:
(1)原对偶问题变为:

(2)原分类函数变为:

求解的过程中,只涉及到了高维特征空间中的内积运算,由于特征空间的维数可能会非常大,例如:若原始空间为二维,映射后的特征空间为5维,若原始空间为三维,映射后的特征空间将是19维,之后甚至可能出现无穷维,根本无法进行内积运算了,此时便引出了核函数(Kernel)的概念。

因此,核函数可以直接计算隐式映射到高维特征空间后的向量内积,而不需要显式地写出映射后的结果,它虽然完成了将特征从低维到高维的转换,但最终却是在低维空间中完成向量内积计算,与高维特征空间中的计算等效**(低维计算,高维表现)**,从而避免了直接在高维空间无法计算的问题。引入核函数后,原来的对偶问题与分类函数则变为:
(1)对偶问题:

(2)分类函数:

因此,在线性不可分问题中,核函数的选择成了支持向量机的最大变数,若选择了不合适的核函数,则意味着将样本映射到了一个不合适的特征空间,则极可能导致性能不佳。同时,核函数需要满足以下这个必要条件:

由于核函数的构造十分困难,通常我们都是从一些常用的核函数中选择,下面列出了几种常用的核函数:

6.5 软间隔支持向量机
前面的讨论中,我们主要解决了两个问题:当数据线性可分时,直接使用最大间隔的超平面划分;当数据线性不可分时,则通过核函数将数据映射到高维特征空间,使之线性可分。然而在现实问题中,对于某些情形还是很难处理,例如数据中有噪声的情形,噪声数据(outlier)本身就偏离了正常位置,但是在前面的SVM模型中,我们要求所有的样本数据都必须满足约束,如果不要这些噪声数据还好,当加入这些outlier后导致划分超平面被挤歪了,如下图所示,对支持向量机的泛化性能造成很大的影响。

为了解决这一问题,我们需要允许某一些数据点不满足约束,即可以在一定程度上偏移超平面,同时使得不满足约束的数据点尽可能少,这便引出了**“软间隔”支持向量机**的概念
- 允许某些数据点不满足约束y(w'x+b)≥1;
- 同时又使得不满足约束的样本尽可能少。
这样优化目标变为:

如同阶跃函数,0/1损失函数虽然表示效果最好,但是数学性质不佳。因此常用其它函数作为“替代损失函数”。

支持向量机中的损失函数为hinge损失,引入**“松弛变量”**,目标函数与约束条件可以写为:

其中C为一个参数,控制着目标函数与新引入正则项之间的权重,这样显然每个样本数据都有一个对应的松弛变量,用以表示该样本不满足约束的程度,将新的目标函数转化为拉格朗日函数得到:

按照与之前相同的方法,先让L求关于w,b以及松弛变量的极小,再使用SMO求出α,有:

将w代入L化简,便得到其对偶问题:

将“软间隔”下产生的对偶问题与原对偶问题对比可以发现:新的对偶问题只是约束条件中的α多出了一个上限C,其它的完全相同,因此在引入核函数处理线性不可分问题时,便能使用与“硬间隔”支持向量机完全相同的方法。
七. 贝叶斯分类器
贝叶斯分类器是一种概率框架下的统计学习分类器,对分类任务而言,假设在相关概率都已知的情况下,贝叶斯分类器考虑如何基于这些概率为样本判定最优的类标。在开始介绍贝叶斯决策论之前,我们首先来回顾下概率论委员会常委--贝叶斯公式。

7.1 贝叶斯决策论
若将上述定义中样本空间的划分Bi看做为类标,A看做为一个新的样本,则很容易将条件概率理解为样本A是类别Bi的概率。在机器学习训练模型的过程中,往往我们都试图去优化一个风险函数,因此在概率框架下我们也可以为贝叶斯定义“条件风险”(conditional risk)。

我们的任务就是寻找一个判定准则最小化所有样本的条件风险总和,因此就有了贝叶斯判定准则(Bayes decision rule):为最小化总体风险,只需在每个样本上选择那个使得条件风险最小的类标。

若损失函数λ取0-1损失,则有:

即对于每个样本x,选择其后验概率P(c | x)最大所对应的类标,能使得总体风险函数最小,从而将原问题转化为估计后验概率P(c | x)。一般这里有两种策略来对后验概率进行估计:
- 判别式模型:直接对 P(c | x)进行建模求解。例我们前面所介绍的决策树、神经网络、SVM都是属于判别式模型。
- 生成式模型:通过先对联合分布P(x,c)建模,从而进一步求解 P(c | x)。
贝叶斯分类器就属于生成式模型,基于贝叶斯公式对后验概率P(c | x) 进行一项神奇的变换,巴拉拉能量.... P(c | x)变身:

对于给定的样本x,P(x)与类标无关,P(c)称为类先验概率,p(x | c )称为类条件概率。这时估计后验概率P(c | x)就变成为估计类先验概率和类条件概率的问题。对于先验概率和后验概率,在看这章之前也是模糊了我好久,这里普及一下它们的基本概念。
- 先验概率: 根据以往经验和分析得到的概率。
- 后验概率:后验概率是基于新的信息,修正原来的先验概率后所获得的更接近实际情况的概率估计。
实际上先验概率就是在没有任何结果出来的情况下估计的概率,而后验概率则是在有一定依据后的重新估计,直观意义上后验概率就是条件概率。下面直接上Wiki上的一个例子,简单粗暴快速完事...

回归正题,对于类先验概率P(c),p(c)就是样本空间中各类样本所占的比例,根据大数定理(当样本足够多时,频率趋于稳定等于其概率),这样当训练样本充足时,p(c)可以使用各类出现的频率来代替。因此只剩下类条件概率p(x | c ),它表达的意思是在类别c中出现x的概率,它涉及到属性的联合概率问题,若只有一个离散属性还好,当属性多时采用频率估计起来就十分困难,因此这里一般采用极大似然法进行估计。
7.2 极大似然法
极大似然估计(Maximum Likelihood Estimation,简称MLE),是一种根据数据采样来估计概率分布的经典方法。常用的策略是先假定总体具有某种确定的概率分布,再基于训练样本对概率分布的参数进行估计。运用到类条件概率p(x | c )中,假设p(x | c )服从一个参数为θ的分布,问题就变为根据已知的训练样本来估计θ。极大似然法的核心思想就是:估计出的参数使得已知样本出现的概率最大,即使得训练数据的似然最大。

所以,贝叶斯分类器的训练过程就是参数估计。总结最大似然法估计参数的过程,一般分为以下四个步骤:
- 1.写出似然函数;
- 2.对似然函数取对数,并整理;
- 3.求导数,令偏导数为0,得到似然方程组;
- 4.解似然方程组,得到所有参数即为所求。
例如:假设样本属性都是连续值,p(x | c )服从一个多维高斯分布,则通过MLE计算出的参数刚好分别为:

上述结果看起来十分合乎实际,但是采用最大似然法估计参数的效果很大程度上依赖于作出的假设是否合理,是否符合潜在的真实数据分布。这就需要大量的经验知识,搞统计越来越值钱也是这个道理,大牛们掐指一算比我们搬砖几天更有效果。
7.3 朴素贝叶斯分类器
不难看出:原始的贝叶斯分类器最大的问题在于联合概率密度函数的估计,首先需要根据经验来假设联合概率分布,其次当属性很多时,训练样本往往覆盖不够,参数的估计会出现很大的偏差。为了避免这个问题,朴素贝叶斯分类器(naive Bayes classifier)采用了“属性条件独立性假设”,即样本数据的所有属性之间相互独立。这样类条件概率p(x | c )可以改写为:

这样,为每个样本估计类条件概率变成为每个样本的每个属性估计类条件概率。

相比原始贝叶斯分类器,朴素贝叶斯分类器基于单个的属性计算类条件概率更加容易操作,需要注意的是:若某个属性值在训练集中和某个类别没有一起出现过,这样会抹掉其它的属性信息,因为该样本的类条件概率被计算为0。因此在估计概率值时,常常用进行平滑(smoothing)处理,拉普拉斯修正(Laplacian correction)就是其中的一种经典方法,具体计算方法如下:

当训练集越大时,拉普拉斯修正引入的影响越来越小。对于贝叶斯分类器,模型的训练就是参数估计,因此可以事先将所有的概率储存好,当有新样本需要判定时,直接查表计算即可。
八. EM算法
EM(Expectation-Maximization)算法是一种常用的估计参数隐变量的利器,也称为“期望最大算法”,是数据挖掘的十大经典算法之一。EM算法主要应用于训练集样本不完整即存在隐变量时的情形(例如某个属性值未知),通过其独特的“两步走”策略能较好地估计出隐变量的值。
8.1 EM算法思想
EM是一种迭代式的方法,它的基本思想就是:若样本服从的分布参数θ已知,则可以根据已观测到的训练样本推断出隐变量Z的期望值(E步),若Z的值已知则运用最大似然法估计出新的θ值(M步)。重复这个过程直到Z和θ值不再发生变化。
简单来讲:假设我们想估计A和B这两个参数,在开始状态下二者都是未知的,但如果知道了A的信息就可以得到B的信息,反过来知道了B也就得到了A。可以考虑首先赋予A某种初值,以此得到B的估计值,然后从B的当前值出发,重新估计A的取值,这个过程一直持续到收敛为止。

现在再来回想聚类的代表算法K-Means:【首先随机选择类中心=>将样本点划分到类簇中=>重新计算类中心=>不断迭代直至收敛】,不难发现这个过程和EM迭代的方法极其相似,事实上,若将样本的类别看做为“隐变量”(latent variable)Z,类中心看作样本的分布参数θ,K-Means就是通过EM算法来进行迭代的,与我们这里不同的是,K-Means的目标是最小化样本点到其对应类中心的距离和,上述为极大化似然函数。
8.2 EM算法数学推导
在上篇极大似然法中,当样本属性值都已知时,我们很容易通过极大化对数似然,接着对每个参数求偏导计算出参数的值。但当存在隐变量时,就无法直接求解,此时我们通常最大化已观察数据的对数“边际似然”(marginal likelihood)。

这时候,通过边缘似然将隐变量Z引入进来,对于参数估计,现在与最大似然不同的只是似然函数式中多了一个未知的变量Z,也就是说我们的目标是找到适合的θ和Z让L(θ)最大,这样我们也可以分别对未知的θ和Z求偏导,再令其等于0。
然而观察上式可以发现,和的对数(ln(x1+x2+x3))求导十分复杂,那能否通过变换上式得到一种求导简单的新表达式呢?这时候 Jensen不等式就派上用场了,先回顾一下高等数学凸函数的内容:
Jensen's inequality:过一个凸函数上任意两点所作割线一定在这两点间的函数图象的上方。理解起来也十分简单,对于凸函数f(x)''>0,即曲线的变化率是越来越大单调递增的,所以函数越到后面增长越厉害,这样在一个区间下,函数的均值就会大一些了。

因为ln(*)函数为凹函数,故可以将上式“和的对数”变为“对数的和”,这样就很容易求导了。

接着求解Qi和θ:首先固定θ(初始值),通过求解Qi使得J(θ,Q)在θ处与L(θ)相等,即求出L(θ)的下界;然后再固定Qi,调整θ,最大化下界J(θ,Q)。不断重复两个步骤直到稳定。通过jensen不等式的性质,Qi的计算公式实际上就是后验概率:

通过数学公式的推导,简单来理解这一过程:固定θ计算Q的过程就是在建立L(θ)的下界,即通过jenson不等式得到的下界(E步);固定Q计算θ则是使得下界极大化(M步),从而不断推高边缘似然L(θ)。从而循序渐进地计算出L(θ)取得极大值时隐变量Z的估计值。
EM算法也可以看作一种“坐标下降法”,首先固定一个值,对另外一个值求极值,不断重复直到收敛。这时候也许大家就有疑问,问什么不直接这两个家伙求偏导用梯度下降呢?这时候就是坐标下降的优势,有些特殊的函数,例如曲线函数z=y^2+x^2+x^2y+xy+...,无法直接求导,这时如果先固定其中的一个变量,再对另一个变量求极值,则变得可行。

8.3 EM算法流程
看完数学推导,算法的流程也就十分简单了,这里有两个版本,版本一来自西瓜书,周天使的介绍十分简洁;版本二来自于大牛的博客。结合着数学推导,自认为版本二更具有逻辑性,两者唯一的区别就在于版本二多出了红框的部分.
版本一:

版本二:

九. 集成学习
顾名思义,集成学习(ensemble learning)指的是将多个学习器进行有效地结合,组建一个“学习器委员会”,其中每个学习器担任委员会成员并行使投票表决权,使得委员会最后的决定更能够四方造福普度众生~...~,即其泛化性能要能优于其中任何一个学习器。
9.1 个体与集成
集成学习的基本结构为:先产生一组个体学习器,再使用某种策略将它们结合在一起。集成模型如下图所示:

在上图的集成模型中,若个体学习器都属于同一类别,例如都是决策树或都是神经网络,则称该集成为同质的(homogeneous);若个体学习器包含多种类型的学习算法,例如既有决策树又有神经网络,则称该集成为异质的(heterogenous)。
同质集成:个体学习器称为“基学习器”(base learner),对应的学习算法为“基学习算法”(base learning algorithm)。 异质集成:个体学习器称为“组件学习器”(component learner)或直称为“个体学习器”。
上面我们已经提到要让集成起来的泛化性能比单个学习器都要好,虽说团结力量大但也有木桶短板理论调皮捣蛋,那如何做到呢?这就引出了集成学习的两个重要概念:准确性和多样性(diversity)。准确性指的是个体学习器不能太差,要有一定的准确度;多样性则是个体学习器之间的输出要具有差异性。通过下面的这三个例子可以很容易看出这一点,准确度较高,差异度也较高,可以较好地提升集成性能。

现在考虑二分类的简单情形,假设基分类器之间相互独立(能提供较高的差异度),且错误率相等为 ε,则可以将集成器的预测看做一个伯努利实验,易知当所有基分类器中不足一半预测正确的情况下,集成器预测错误,所以集成器的错误率可以计算为:

此时,集成器错误率随着基分类器的个数的增加呈指数下降,但前提是基分类器之间相互独立,在实际情形中显然是不可能的,假设训练有A和B两个分类器,对于某个测试样本,显然满足:P(A=1 | B=1)> P(A=1),因为A和B为了解决相同的问题而训练,因此在预测新样本时存在着很大的联系。因此,个体学习器的“准确性”和“差异性”本身就是一对矛盾的变量,准确性高意味着牺牲多样性,所以产生“好而不同”的个体学习器正是集成学习研究的核心。现阶段有三种主流的集成学习方法:Boosting、Bagging以及随机森林(Random Forest),接下来将进行逐一介绍。
9.2 Boosting
Boosting是一种串行的工作机制,即个体学习器的训练存在依赖关系,必须一步一步序列化进行。其基本思想是:增加前一个基学习器在训练训练过程中预测错误样本的权重,使得后续基学习器更加关注这些打标错误的训练样本,尽可能纠正这些错误,一直向下串行直至产生需要的T个基学习器,Boosting最终对这T个学习器进行加权结合,产生学习器委员会。
Boosting族算法最著名、使用最为广泛的就是AdaBoost,因此下面主要是对AdaBoost算法进行介绍。AdaBoost使用的是指数损失函数,因此AdaBoost的权值与样本分布的更新都是围绕着最小化指数损失函数进行的。看到这里回想一下之前的机器学习算法,不难发现机器学习的大部分带参模型只是改变了最优化目标中的损失函数:如果是Square loss,那就是最小二乘了;如果是Hinge Loss,那就是著名的SVM了;如果是log-Loss,那就是Logistic Regression了。
定义基学习器的集成为加权结合,则有:

AdaBoost算法的指数损失函数定义为:

具体说来,整个Adaboost 迭代算法分为3步:
- 初始化训练数据的权值分布。如果有N个样本,则每一个训练样本最开始时都被赋予相同的权值:1/N。
- 训练弱分类器。具体训练过程中,如果某个样本点已经被准确地分类,那么在构造下一个训练集中,它的权值就被降低;相反,如果某个样本点没有被准确地分类,那么它的权值就得到提高。然后,权值更新过的样本集被用于训练下一个分类器,整个训练过程如此迭代地进行下去。
- 将各个训练得到的弱分类器组合成强分类器。各个弱分类器的训练过程结束后,加大分类误差率小的弱分类器的权重,使其在最终的分类函数中起着较大的决定作用,而降低分类误差率大的弱分类器的权重,使其在最终的分类函数中起着较小的决定作用。
整个AdaBoost的算法流程如下所示:

可以看出:AdaBoost的核心步骤就是计算基学习器权重和样本权重分布,那为何是上述的计算公式呢?这就涉及到了我们之前为什么说大部分带参机器学习算法只是改变了损失函数,就是因为大部分模型的参数都是通过最优化损失函数(可能还加个规则项)而计算(梯度下降,坐标下降等)得到,这里正是通过最优化指数损失函数从而得到这两个参数的计算公式,具体的推导过程此处不进行展开。
Boosting算法要求基学习器能对特定分布的数据进行学习,即每次都更新样本分布权重,这里书上提到了两种方法:“重赋权法”(re-weighting)和“重采样法”(re-sampling),书上的解释有些晦涩,这里进行展开一下:
重赋权法 : 对每个样本附加一个权重,这时涉及到样本属性与标签的计算,都需要乘上一个权值。 重采样法 : 对于一些无法接受带权样本的及学习算法,适合用“重采样法”进行处理。方法大致过程是,根据各个样本的权重,对训练数据进行重采样,初始时样本权重一样,每个样本被采样到的概率一致,每次从N个原始的训练样本中按照权重有放回采样N个样本作为训练集,然后计算训练集错误率,然后调整权重,重复采样,集成多个基学习器。
从偏差-方差分解来看:Boosting算法主要关注于降低偏差,每轮的迭代都关注于训练过程中预测错误的样本,将弱学习提升为强学习器。从AdaBoost的算法流程来看,标准的AdaBoost只适用于二分类问题。在此,当选为数据挖掘十大算法之一的AdaBoost介绍到这里,能够当选正是说明这个算法十分婀娜多姿,背后的数学证明和推导充分证明了这一点,限于篇幅不再继续展开。
9.3 Bagging与Random Forest
相比之下,Bagging与随机森林算法就简洁了许多,上面已经提到产生“好而不同”的个体学习器是集成学习研究的核心,即在保证基学习器准确性的同时增加基学习器之间的多样性。而这两种算法的基本思(tao)想(lu)都是通过“自助采样”的方法来增加多样性。
9.3.1 Bagging
Bagging是一种并行式的集成学习方法,即基学习器的训练之间没有前后顺序可以同时进行,Bagging使用“有放回”采样的方式选取训练集,对于包含m个样本的训练集,进行m次有放回的随机采样操作,从而得到m个样本的采样集,这样训练集中有接近36.8%的样本没有被采到。按照相同的方式重复进行,我们就可以采集到T个包含m个样本的数据集,从而训练出T个基学习器,最终对这T个基学习器的输出进行结合。

Bagging算法的流程如下所示:

可以看出Bagging主要通过样本的扰动来增加基学习器之间的多样性,因此Bagging的基学习器应为那些对训练集十分敏感的不稳定学习算法,例如:神经网络与决策树等。从偏差-方差分解来看,Bagging算法主要关注于降低方差,即通过多次重复训练提高稳定性。不同于AdaBoost的是,Bagging可以十分简单地移植到多分类、回归等问题。总的说起来则是:AdaBoost关注于降低偏差,而Bagging关注于降低方差。
9.3.2 随机森林
随机森林(Random Forest)是Bagging的一个拓展体,它的基学习器固定为决策树,多棵树也就组成了森林,而“随机”则在于选择划分属性的随机,随机森林在训练基学习器时,也采用有放回采样的方式添加样本扰动,同时它还引入了一种属性扰动,即在基决策树的训练过程中,在选择划分属性时,RF先从候选属性集中随机挑选出一个包含K个属性的子集,再从这个子集中选择最优划分属性,一般推荐K=log2(d)。
这样随机森林中基学习器的多样性不仅来自样本扰动,还来自属性扰动,从而进一步提升了基学习器之间的差异度。相比决策树的Bagging集成,随机森林的起始性能较差(由于属性扰动,基决策树的准确度有所下降),但随着基学习器数目的增多,随机森林往往会收敛到更低的泛化误差。同时不同于Bagging中决策树从所有属性集中选择最优划分属性,随机森林只在属性集的一个子集中选择划分属性,因此训练效率更高。

9.4 结合策略
结合策略指的是在训练好基学习器后,如何将这些基学习器的输出结合起来产生集成模型的最终输出,下面将介绍一些常用的结合策略:
9.4.1 平均法(回归问题)


易知简单平均法是加权平均法的一种特例,加权平均法可以认为是集成学习研究的基本出发点。由于各个基学习器的权值在训练中得出,一般而言,在个体学习器性能相差较大时宜使用加权平均法,在个体学习器性能相差较小时宜使用简单平均法。
9.4.2 投票法(分类问题)



绝对多数投票法(majority voting)提供了拒绝选项,这在可靠性要求很高的学习任务中是一个很好的机制。同时,对于分类任务,各个基学习器的输出值有两种类型,分别为类标记和类概率。

一些在产生类别标记的同时也生成置信度的学习器,置信度可转化为类概率使用,一般基于类概率进行结合往往比基于类标记进行结合的效果更好,需要注意的是对于异质集成,其类概率不能直接进行比较,此时需要将类概率转化为类标记输出,然后再投票。
9.4.3 学习法
学习法是一种更高级的结合策略,即学习出一种“投票”的学习器,Stacking是学习法的典型代表。Stacking的基本思想是:首先训练出T个基学习器,对于一个样本它们会产生T个输出,将这T个基学习器的输出与该样本的真实标记作为新的样本,m个样本就会产生一个m*T的样本集,来训练一个新的“投票”学习器。投票学习器的输入属性与学习算法对Stacking集成的泛化性能有很大的影响,书中已经提到:投票学习器采用类概率作为输入属性,选用多响应线性回归(MLR)一般会产生较好的效果。

9.5 多样性(diversity)
在集成学习中,基学习器之间的多样性是影响集成器泛化性能的重要因素。因此增加多样性对于集成学习研究十分重要,一般的思路是在学习过程中引入随机性,常见的做法主要是对数据样本、输入属性、输出表示、算法参数进行扰动。
数据样本扰动,即利用具有差异的数据集来训练不同的基学习器。例如:有放回自助采样法,但此类做法只对那些不稳定学习算法十分有效,例如:决策树和神经网络等,训练集的稍微改变能导致学习器的显著变动。 输入属性扰动,即随机选取原空间的一个子空间来训练基学习器。例如:随机森林,从初始属性集中抽取子集,再基于每个子集来训练基学习器。但若训练集只包含少量属性,则不宜使用属性扰动。 输出表示扰动,此类做法可对训练样本的类标稍作变动,或对基学习器的输出进行转化。 算法参数扰动,通过随机设置不同的参数,例如:神经网络中,随机初始化权重与随机设置隐含层节点数。
十. 聚类算法
聚类是一种经典的无监督学习方法,无监督学习的目标是通过对无标记训练样本的学习,发掘和揭示数据集本身潜在的结构与规律,即不依赖于训练数据集的类标记信息。聚类则是试图将数据集的样本划分为若干个互不相交的类簇,从而每个簇对应一个潜在的类别。
聚类直观上来说是将相似的样本聚在一起,从而形成一个类簇(cluster)。那首先的问题是如何来度量相似性(similarity measure)呢?这便是距离度量,在生活中我们说差别小则相似,对应到多维样本,每个样本可以对应于高维空间中的一个数据点,若它们的距离相近,我们便可以称它们相似。那接着如何来评价聚类结果的好坏呢?这便是性能度量,性能度量为评价聚类结果的好坏提供了一系列有效性指标。
10.1 距离度量
谈及距离度量,最熟悉的莫过于欧式距离了,从年头一直用到年尾的距离计算公式:即对应属性之间相减的平方和再开根号。度量距离还有其它的很多经典方法,通常它们需要满足一些基本性质:

最常用的距离度量方法是**“闵可夫斯基距离”(Minkowski distance)**:

当p=1时,闵可夫斯基距离即曼哈顿距离(Manhattan distance):

当p=2时,闵可夫斯基距离即欧氏距离(Euclidean distance):

我们知道属性分为两种:连续属性和离散属性(有限个取值)。对于连续值的属性,一般都可以被学习器所用,有时会根据具体的情形作相应的预处理,例如:归一化等;而对于离散值的属性,需要作下面进一步的处理:
若属性值之间存在序关系,则可以将其转化为连续值,例如:身高属性“高”“中等”“矮”,可转化为{1, 0.5, 0}。 若属性值之间不存在序关系,则通常将其转化为向量的形式,例如:性别属性“男”“女”,可转化为{(1,0),(0,1)}。
在进行距离度量时,易知连续属性和存在序关系的离散属性都可以直接参与计算,因为它们都可以反映一种程度,我们称其为“有序属性”;而对于不存在序关系的离散属性,我们称其为:“无序属性”,显然无序属性再使用闵可夫斯基距离就行不通了。
对于无序属性,我们一般采用VDM进行距离的计算,例如:对于离散属性的两个取值a和b,定义:

于是,在计算两个样本之间的距离时,我们可以将闵可夫斯基距离和VDM混合在一起进行计算:

若我们定义的距离计算方法是用来度量相似性,例如下面将要讨论的聚类问题,即距离越小,相似性越大,反之距离越大,相似性越小。这时距离的度量方法并不一定需要满足前面所说的四个基本性质,这样的方法称为:非度量距离(non-metric distance)。
10.2 性能度量
由于聚类算法不依赖于样本的真实类标,就不能像监督学习的分类那般,通过计算分对分错(即精确度或错误率)来评价学习器的好坏或作为学习过程中的优化目标。一般聚类有两类性能度量指标:外部指标和内部指标。
10.2.1 外部指标
即将聚类结果与某个参考模型的结果进行比较,以参考模型的输出作为标准,来评价聚类好坏。假设聚类给出的结果为λ,参考模型给出的结果是λ*,则我们将样本进行两两配对,定义:

显然a和b代表着聚类结果好坏的正能量,b和c则表示参考结果和聚类结果相矛盾,基于这四个值可以导出以下常用的外部评价指标:

10.2.2 内部指标
内部指标即不依赖任何外部模型,直接对聚类的结果进行评估,聚类的目的是想将那些相似的样本尽可能聚在一起,不相似的样本尽可能分开,直观来说:簇内高内聚紧紧抱团,簇间低耦合老死不相往来。定义:

基于上面的四个距离,可以导出下面这些常用的内部评价指标:

10.3 原型聚类
原型聚类即“基于原型的聚类”(prototype-based clustering),原型表示模板的意思,就是通过参考一个模板向量或模板分布的方式来完成聚类的过程,常见的K-Means便是基于簇中心来实现聚类,混合高斯聚类则是基于簇分布来实现聚类。
10.3.1 K-Means
K-Means的思想十分简单,首先随机指定类中心,根据样本与类中心的远近划分类簇,接着重新计算类中心,迭代直至收敛。但是其中迭代的过程并不是主观地想象得出,事实上,若将样本的类别看做为“隐变量”(latent variable),类中心看作样本的分布参数,这一过程正是通过EM算法的两步走策略而计算出,其根本的目的是为了最小化平方误差函数E:

K-Means的算法流程如下所示:

10.3.2 学习向量量化(LVQ)
LVQ也是基于原型的聚类算法,与K-Means不同的是,LVQ使用样本真实类标记辅助聚类,首先LVQ根据样本的类标记,从各类中分别随机选出一个样本作为该类簇的原型,从而组成了一个原型特征向量组,接着从样本集中随机挑选一个样本,计算其与原型向量组中每个向量的距离,并选取距离最小的原型向量所在的类簇作为它的划分结果,再与真实类标比较。
若划分结果正确,则对应原型向量向这个样本靠近一些 若划分结果不正确,则对应原型向量向这个样本远离一些
LVQ算法的流程如下所示:

10.3.3 高斯混合聚类
现在可以看出K-Means与LVQ都试图以类中心作为原型指导聚类,高斯混合聚类则采用高斯分布来描述原型。现假设每个类簇中的样本都服从一个多维高斯分布,那么空间中的样本可以看作由k个多维高斯分布混合而成。
对于多维高斯分布,其概率密度函数如下所示:

其中u表示均值向量,∑表示协方差矩阵,可以看出一个多维高斯分布完全由这两个参数所确定。接着定义高斯混合分布为:

α称为混合系数,这样空间中样本的采集过程则可以抽象为:(1)先选择一个类簇(高斯分布),(2)再根据对应高斯分布的密度函数进行采样,这时候贝叶斯公式又能大展身手了:

此时只需要选择PM最大时的类簇并将该样本划分到其中,看到这里很容易发现:这和那个传说中的贝叶斯分类不是神似吗,都是通过贝叶斯公式展开,然后计算类先验概率和类条件概率。但遗憾的是:这里没有真实类标信息,对于类条件概率,并不能像贝叶斯分类那样通过最大似然法美好地计算出来,因为这里的样本可能属于所有的类簇,这里的似然函数变为:

可以看出:简单的最大似然法根本无法求出所有的参数,这样PM也就没法计算。这里就要召唤出之前的EM大法,首先对高斯分布的参数及混合系数进行随机初始化,计算出各个PM(即γji,第i个样本属于j类),再最大化似然函数(即LL(D)分别对α、u和∑求偏导 ),对参数进行迭代更新。

高斯混合聚类的算法流程如下图所示:

10.4 密度聚类
密度聚类则是基于密度的聚类,它从样本分布的角度来考察样本之间的可连接性,并基于可连接性(密度可达)不断拓展疆域(类簇)。其中最著名的便是DBSCAN算法,首先定义以下概念:


简单来理解DBSCAN便是:找出一个核心对象所有密度可达的样本集合形成簇。首先从数据集中任选一个核心对象A,找出所有A密度可达的样本集合,将这些样本形成一个密度相连的类簇,直到所有的核心对象都遍历完。DBSCAN算法的流程如下图所示:

10.5 层次聚类
层次聚类是一种基于树形结构的聚类方法,常用的是自底向上的结合策略(AGNES算法)。假设有N个待聚类的样本,其基本步骤是:
1.初始化-->把每个样本归为一类,计算每两个类之间的距离,也就是样本与样本之间的相似度; 2.寻找各个类之间最近的两个类,把他们归为一类(这样类的总数就少了一个); 3.重新计算新生成的这个类与各个旧类之间的相似度; 4.重复2和3直到所有样本点都归为一类,结束。
可以看出其中最关键的一步就是计算两个类簇的相似度,这里有多种度量方法:
-
单链接(single-linkage):取类间最小距离。

-
全链接(complete-linkage):取类间最大距离

-
均链接(average-linkage):取类间两两的平均距离

很容易看出:单链接的包容性极强,稍微有点暧昧就当做是自己人了,全链接则是坚持到底,只要存在缺点就坚决不合并,均连接则是从全局出发顾全大局。层次聚类法的算法流程如下所示:

在此聚类算法就介绍完毕,分类/聚类都是机器学习中最常见的任务,我实验室的大Boss也是靠着聚类起家,从此走上人生事业钱途...之巅峰,在书最后的阅读材料还看见Boss的名字,所以这章也是必读不可了...
十一. 降维与度量学习
样本的特征数称为维数(dimensionality),当维数非常大时,也就是现在所说的“维数灾难”,具体表现在:在高维情形下,数据样本将变得十分稀疏,因为此时要满足训练样本为“密采样”的总体样本数目是一个触不可及的天文数字,谓可远观而不可亵玩焉...训练样本的稀疏使得其代表总体分布的能力大大减弱,从而消减了学习器的泛化能力;同时当维数很高时,计算距离也变得十分复杂,甚至连计算内积都不再容易,这也是为什么支持向量机(SVM)使用核函数**“低维计算,高维表现”**的原因。
缓解维数灾难的一个重要途径就是降维,即通过某种数学变换将原始高维空间转变到一个低维的子空间。在这个子空间中,样本的密度将大幅提高,同时距离计算也变得容易。这时也许会有疑问,这样降维之后不是会丢失原始数据的一部分信息吗?这是因为在很多实际的问题中,虽然训练数据是高维的,但是与学习任务相关也许仅仅是其中的一个低维子空间,也称为一个低维嵌入,例如:数据属性中存在噪声属性、相似属性或冗余属性等,对高维数据进行降维能在一定程度上达到提炼低维优质属性或降噪的效果。
11.1 K近邻学习
k近邻算法简称kNN(k-Nearest Neighbor),是一种经典的监督学习方法,同时也实力担当入选数据挖掘十大算法。其工作机制十分简单粗暴:给定某个测试样本,kNN基于某种距离度量在训练集中找出与其距离最近的k个带有真实标记的训练样本,然后给基于这k个邻居的真实标记来进行预测,类似于前面集成学习中所讲到的基学习器结合策略:分类任务采用投票法,回归任务则采用平均法。接下来本篇主要就kNN分类进行讨论。

从上图【来自Wiki】中我们可以看到,图中有两种类型的样本,一类是蓝色正方形,另一类是红色三角形。而那个绿色圆形是我们待分类的样本。基于kNN算法的思路,我们很容易得到以下结论:
如果K=3,那么离绿色点最近的有2个红色三角形和1个蓝色的正方形,这3个点投票,于是绿色的这个待分类点属于红色的三角形。 如果K=5,那么离绿色点最近的有2个红色三角形和3个蓝色的正方形,这5个点投票,于是绿色的这个待分类点属于蓝色的正方形。
可以发现:kNN虽然是一种监督学习方法,但是它却没有显式的训练过程,而是当有新样本需要预测时,才来计算出最近的k个邻居,因此kNN是一种典型的懒惰学习方法,再来回想一下朴素贝叶斯的流程,训练的过程就是参数估计,因此朴素贝叶斯也可以懒惰式学习,此类技术在训练阶段开销为零,待收到测试样本后再进行计算。相应地我们称那些一有训练数据立马开工的算法为“急切学习”,可见前面我们学习的大部分算法都归属于急切学习。
很容易看出:kNN算法的核心在于k值的选取以及距离的度量。k值选取太小,模型很容易受到噪声数据的干扰,例如:极端地取k=1,若待分类样本正好与一个噪声数据距离最近,就导致了分类错误;若k值太大, 则在更大的邻域内进行投票,此时模型的预测能力大大减弱,例如:极端取k=训练样本数,就相当于模型根本没有学习,所有测试样本的预测结果都是一样的。一般地我们都通过交叉验证法来选取一个适当的k值。

对于距离度量,不同的度量方法得到的k个近邻不尽相同,从而对最终的投票结果产生了影响,因此选择一个合适的距离度量方法也十分重要。在上一篇聚类算法中,在度量样本相似性时介绍了常用的几种距离计算方法,包括闵可夫斯基距离,曼哈顿距离,VDM等。在实际应用中,kNN的距离度量函数一般根据样本的特性来选择合适的距离度量,同时应对数据进行去量纲/归一化处理来消除大量纲属性的强权政治影响。
11.2 MDS算法
不管是使用核函数升维还是对数据降维,我们都希望原始空间样本点之间的距离在新空间中基本保持不变,这样才不会使得原始空间样本之间的关系及总体分布发生较大的改变。**“多维缩放”(MDS)**正是基于这样的思想,MDS要求原始空间样本之间的距离在降维后的低维空间中得以保持。
假定m个样本在原始空间中任意两两样本之间的距离矩阵为D∈R(m*m),我们的目标便是获得样本在低维空间中的表示Z∈R(d'*m , d'< d),且任意两个样本在低维空间中的欧式距离等于原始空间中的距离,即||zi-zj||=Dist(ij)。因此接下来我们要做的就是根据已有的距离矩阵D来求解出降维后的坐标矩阵Z。

令降维后的样本坐标矩阵Z被中心化,中心化是指将每个样本向量减去整个样本集的均值向量,故所有样本向量求和得到一个零向量。这样易知:矩阵B的每一列以及每一列求和均为0,因为提取公因子后都有一项为所有样本向量的和向量。

根据上面矩阵B的特征,我们很容易得到等式(2)、(3)以及(4):

这时根据(1)--(4)式我们便可以计算出bij,即bij=(1)-(2)(1/m)-(3)(1/m)+(4)*(1/(m^2)),再逐一地计算每个b(ij),就得到了降维后低维空间中的内积矩阵B(B=Z'*Z),只需对B进行特征值分解便可以得到Z。MDS的算法流程如下图所示:

11.3 主成分分析(PCA)
不同于MDS采用距离保持的方法,主成分分析(PCA)直接通过一个线性变换,将原始空间中的样本投影到新的低维空间中。简单来理解这一过程便是:PCA采用一组新的基来表示样本点,其中每一个基向量都是原来基向量的线性组合,通过使用尽可能少的新基向量来表出样本,从而达到降维的目的。
假设使用d'个新基向量来表示原来样本,实质上是将样本投影到一个由d'个基向量确定的一个超平面上(即舍弃了一些维度),要用一个超平面对空间中所有高维样本进行恰当的表达,最理想的情形是:若这些样本点都能在超平面上表出且这些表出在超平面上都能够很好地分散开来。但是一般使用较原空间低一些维度的超平面来做到这两点十分不容易,因此我们退一步海阔天空,要求这个超平面应具有如下两个性质:
最近重构性:样本点到超平面的距离足够近,即尽可能在超平面附近; 最大可分性:样本点在超平面上的投影尽可能地分散开来,即投影后的坐标具有区分性。
这里十分神奇的是:最近重构性与最大可分性虽然从不同的出发点来定义优化问题中的目标函数,但最终这两种特性得到了完全相同的优化问题:

接着使用拉格朗日乘子法求解上面的优化问题,得到:

因此只需对协方差矩阵进行特征值分解即可求解出W,PCA算法的整个流程如下图所示:

另一篇博客给出更通俗更详细的理解:主成分分析解析(基于最大方差理论)
11.4 核化线性降维
说起机器学习你中有我/我中有你/水乳相融...在这里能够得到很好的体现。正如SVM在处理非线性可分时,通过引入核函数将样本投影到高维特征空间,接着在高维空间再对样本点使用超平面划分。这里也是相同的问题:若我们的样本数据点本身就不是线性分布,那还如何使用一个超平面去近似表出呢?因此也就引入了核函数,即先将样本映射到高维空间,再在高维空间中使用线性降维的方法。下面主要介绍**核化主成分分析(KPCA)**的思想。
若核函数的形式已知,即我们知道如何将低维的坐标变换为高维坐标,这时我们只需先将数据映射到高维特征空间,再在高维空间中运用PCA即可。但是一般情况下,我们并不知道核函数具体的映射规则,例如:Sigmoid、高斯核等,我们只知道如何计算高维空间中的样本内积,这时就引出了KPCA的一个重要创新之处:即空间中的任一向量,都可以由该空间中的所有样本线性表示。证明过程也十分简单:

这样我们便可以将高维特征空间中的投影向量wi使用所有高维样本点线性表出,接着代入PCA的求解问题,得到:

化简到最后一步,发现结果十分的美妙,只需对核矩阵K进行特征分解,便可以得出投影向量wi对应的系数向量α,因此选取特征值前d'大对应的特征向量便是d'个系数向量。这时对于需要降维的样本点,只需按照以下步骤便可以求出其降维后的坐标。可以看出:KPCA在计算降维后的坐标表示时,需要与所有样本点计算核函数值并求和,因此该算法的计算开销十分大。

11.5 流形学习
流形学习(manifold learning)是一种借助拓扑流形概念的降维方法,流形是指在局部与欧式空间同胚的空间,即在局部与欧式空间具有相同的性质,能用欧氏距离计算样本之间的距离。这样即使高维空间的分布十分复杂,但是在局部上依然满足欧式空间的性质,基于流形学习的降维正是这种**“邻域保持”的思想。其中等度量映射(Isomap)试图在降维前后保持邻域内样本之间的距离,而局部线性嵌入(LLE)则是保持邻域内样本之间的线性关系**,下面将分别对这两种著名的流行学习方法进行介绍。
11.5.1 等度量映射(Isomap)
等度量映射的基本出发点是:高维空间中的直线距离具有误导性,因为有时高维空间中的直线距离在低维空间中是不可达的。因此利用流形在局部上与欧式空间同胚的性质,可以使用近邻距离来逼近测地线距离,即对于一个样本点,它与近邻内的样本点之间是可达的,且距离使用欧式距离计算,这样整个样本空间就形成了一张近邻图,高维空间中两个样本之间的距离就转为最短路径问题。可采用著名的Dijkstra算法或Floyd算法计算最短距离,得到高维空间中任意两点之间的距离后便可以使用MDS算法来其计算低维空间中的坐标。

从MDS算法的描述中我们可以知道:MDS先求出了低维空间的内积矩阵B,接着使用特征值分解计算出了样本在低维空间中的坐标,但是并没有给出通用的投影向量w,因此对于需要降维的新样本无从下手,书中给出的权宜之计是利用已知高/低维坐标的样本作为训练集学习出一个“投影器”,便可以用高维坐标预测出低维坐标。Isomap算法流程如下图:

对于近邻图的构建,常用的有两种方法:一种是指定近邻点个数,像kNN一样选取k个最近的邻居;另一种是指定邻域半径,距离小于该阈值的被认为是它的近邻点。但两种方法均会出现下面的问题:
若邻域范围指定过大,则会造成“短路问题”,即本身距离很远却成了近邻,将距离近的那些样本扼杀在摇篮。 若邻域范围指定过小,则会造成“断路问题”,即有些样本点无法可达了,整个世界村被划分为互不可达的小部落。
11.5.2 局部线性嵌入(LLE)
不同于Isomap算法去保持邻域距离,LLE算法试图去保持邻域内的线性关系,假定样本xi的坐标可以通过它的邻域样本线性表出:


LLE算法分为两步走,首先第一步根据近邻关系计算出所有样本的邻域重构系数w:

接着根据邻域重构系数不变,去求解低维坐标:

这样利用矩阵M,优化问题可以重写为:

M特征值分解后最小的d'个特征值对应的特征向量组成Z,LLE算法的具体流程如下图所示:

11.6 度量学习
本篇一开始就提到维数灾难,即在高维空间进行机器学习任务遇到样本稀疏、距离难计算等诸多的问题,因此前面讨论的降维方法都试图将原空间投影到一个合适的低维空间中,接着在低维空间进行学习任务从而产生较好的性能。事实上,不管高维空间还是低维空间都潜在对应着一个距离度量,那可不可以直接学习出一个距离度量来等效降维呢?例如:咋们就按照降维后的方式来进行距离的计算,这便是度量学习的初衷。
首先要学习出距离度量必须先定义一个合适的距离度量形式。对两个样本xi与xj,它们之间的平方欧式距离为:

若各个属性重要程度不一样即都有一个权重,则得到加权的平方欧式距离:

此时各个属性之间都是相互独立无关的,但现实中往往会存在属性之间有关联的情形,例如:身高和体重,一般人越高,体重也会重一些,他们之间存在较大的相关性。这样计算距离就不能分属性单独计算,于是就引入经典的马氏距离(Mahalanobis distance):

标准的马氏距离中M是协方差矩阵的逆,马氏距离是一种考虑属性之间相关性且尺度无关(即无须去量纲)的距离度量。

矩阵M也称为“度量矩阵”,为保证距离度量的非负性与对称性,M必须为(半)正定对称矩阵,这样就为度量学习定义好了距离度量的形式,换句话说:度量学习便是对度量矩阵进行学习。现在来回想一下前面我们接触的机器学习不难发现:机器学习算法几乎都是在优化目标函数,从而求解目标函数中的参数。同样对于度量学习,也需要设置一个优化目标,书中简要介绍了错误率和相似性两种优化目标,此处限于篇幅不进行展开。
在此,降维和度量学习就介绍完毕。降维是将原高维空间嵌入到一个合适的低维子空间中,接着在低维空间中进行学习任务;度量学习则是试图去学习出一个距离度量来等效降维的效果,两者都是为了解决维数灾难带来的诸多问题。也许大家最后心存疑惑,那kNN呢,为什么一开头就说了kNN算法,但是好像和后面没有半毛钱关系?正是因为在降维算法中,低维子空间的维数d'通常都由人为指定,因此我们需要使用一些低开销的学习器来选取合适的d',kNN这家伙懒到家了根本无心学习,在训练阶段开销为零,测试阶段也只是遍历计算了距离,因此拿kNN来进行交叉验证就十分有优势了~同时降维后样本密度增大同时距离计算变易,更为kNN来展示它独特的十八般手艺提供了用武之地。
十二. 特征选择与稀疏学习
最近在看论文的过程中,发现对于数据集行和列的叫法颇有不同,故在介绍本篇之前,决定先将最常用的术语罗列一二,以后再见到了不管它脚扑朔还是眼迷离就能一眼识破真身了~对于数据集中的一个对象及组成对象的零件元素:
统计学家常称它们为观测(observation)和变量(variable); 数据库分析师则称其为记录(record)和字段(field); 数据挖掘/机器学习学科的研究者则习惯把它们叫做样本/示例(example/instance)和属性/特征(attribute/feature)。
回归正题,在机器学习中特征选择是一个重要的“数据预处理”(data preprocessing)过程,即试图从数据集的所有特征中挑选出与当前学习任务相关的特征子集,接着再利用数据子集来训练学习器;稀疏学习则是围绕着稀疏矩阵的优良性质,来完成相应的学习任务。
12.1 子集搜索与评价
一般地,我们可以用很多属性/特征来描述一个示例,例如对于一个人可以用性别、身高、体重、年龄、学历、专业、是否吃货等属性来描述,那现在想要训练出一个学习器来预测人的收入。根据生活经验易知:并不是所有的特征都与学习任务相关,例如年龄/学历/专业可能很大程度上影响了收入,身高/体重这些外貌属性也有较小的可能性影响收入,但像是否是一个地地道道的吃货这种属性就八杆子打不着了。因此我们只需要那些与学习任务紧密相关的特征,特征选择便是从给定的特征集合中选出相关特征子集的过程。
与上篇中降维技术有着异曲同工之处的是,特征选择也可以有效地解决维数灾难的难题。具体而言:降维从一定程度起到了提炼优质低维属性和降噪的效果,特征选择则是直接剔除那些与学习任务无关的属性而选择出最佳特征子集。若直接遍历所有特征子集,显然当维数过多时遭遇指数爆炸就行不通了;若采取从候选特征子集中不断迭代生成更优候选子集的方法,则时间复杂度大大减小。这时就涉及到了两个关键环节:1.如何生成候选子集;2.如何评价候选子集的好坏,这便是早期特征选择的常用方法。书本上介绍了贪心算法,分为三种策略:
前向搜索:初始将每个特征当做一个候选特征子集,然后从当前所有的候选子集中选择出最佳的特征子集;接着在上一轮选出的特征子集中添加一个新的特征,同样地选出最佳特征子集;最后直至选不出比上一轮更好的特征子集。 后向搜索:初始将所有特征作为一个候选特征子集;接着尝试去掉上一轮特征子集中的一个特征并选出当前最优的特征子集;最后直到选不出比上一轮更好的特征子集。 双向搜索:将前向搜索与后向搜索结合起来,即在每一轮中既有添加操作也有剔除操作。
对于特征子集的评价,书中给出了一些想法及基于信息熵的方法。假设数据集的属性皆为离散属性,这样给定一个特征子集,便可以通过这个特征子集的取值将数据集合划分为V个子集。例如:A1={男,女},A2={本科,硕士}就可以将原数据集划分为2*2=4个子集,其中每个子集的取值完全相同。这时我们就可以像决策树选择划分属性那样,通过计算信息增益来评价该属性子集的好坏。

此时,信息增益越大表示该属性子集包含有助于分类的特征越多,使用上述这种子集搜索与子集评价相结合的机制,便可以得到特征选择方法。值得一提的是若将前向搜索策略与信息增益结合在一起,与前面我们讲到的ID3决策树十分地相似。事实上,决策树也可以用于特征选择,树节点划分属性组成的集合便是选择出的特征子集。
12.2 过滤式选择(Relief)
过滤式方法是一种将特征选择与学习器训练相分离的特征选择技术,即首先将相关特征挑选出来,再使用选择出的数据子集来训练学习器。Relief是其中著名的代表性算法,它使用一个“相关统计量”来度量特征的重要性,该统计量是一个向量,其中每个分量代表着相应特征的重要性,因此我们最终可以根据这个统计量各个分量的大小来选择出合适的特征子集。
易知Relief算法的核心在于如何计算出该相关统计量。对于数据集中的每个样例xi,Relief首先找出与xi同类别的最近邻与不同类别的最近邻,分别称为猜中近邻(near-hit)与猜错近邻(near-miss),接着便可以分别计算出相关统计量中的每个分量。对于j分量:

直观上理解:对于猜中近邻,两者j属性的距离越小越好,对于猜错近邻,j属性距离越大越好。更一般地,若xi为离散属性,diff取海明距离,即相同取0,不同取1;若xi为连续属性,则diff为曼哈顿距离,即取差的绝对值。分别计算每个分量,最终取平均便得到了整个相关统计量。
标准的Relief算法只用于二分类问题,后续产生的拓展变体Relief-F则解决了多分类问题。对于j分量,新的计算公式如下:

其中pl表示第l类样本在数据集中所占的比例,易知两者的不同之处在于:标准Relief 只有一个猜错近邻,而Relief-F有多个猜错近邻。
12.3 包裹式选择(LVW)
与过滤式选择不同的是,包裹式选择将后续的学习器也考虑进来作为特征选择的评价准则。因此包裹式选择可以看作是为某种学习器量身定做的特征选择方法,由于在每一轮迭代中,包裹式选择都需要训练学习器,因此在获得较好性能的同时也产生了较大的开销。下面主要介绍一种经典的包裹式特征选择方法 --LVW(Las Vegas Wrapper),它在拉斯维加斯框架下使用随机策略来进行特征子集的搜索。拉斯维加斯?怎么听起来那么耳熟,不是那个声名显赫的赌场吗?歪果仁真会玩。怀着好奇科普一下,结果又顺带了一个赌场:
蒙特卡罗算法:采样越多,越近似最优解,一定会给出解,但给出的解不一定是正确解; 拉斯维加斯算法:采样越多,越有机会找到最优解,不一定会给出解,且给出的解一定是正确解。
举个例子,假如筐里有100个苹果,让我每次闭眼拿1个,挑出最大的。于是我随机拿1个,再随机拿1个跟它比,留下大的,再随机拿1个……我每拿一次,留下的苹果都至少不比上次的小。拿的次数越多,挑出的苹果就越大,但我除非拿100次,否则无法肯定挑出了最大的。这个挑苹果的算法,就属于蒙特卡罗算法——尽量找较好的,但不保证是最好的。
而拉斯维加斯算法,则是另一种情况。假如有一把锁,给我100把钥匙,只有1把是对的。于是我每次随机拿1把钥匙去试,打不开就再换1把。我试的次数越多,打开(正确解)的机会就越大,但在打开之前,那些错的钥匙都是没有用的。这个试钥匙的算法,就是拉斯维加斯的——尽量找最好的,但不保证能找到。
LVW算法的具体流程如下所示,其中比较特别的是停止条件参数T的设置,即在每一轮寻找最优特征子集的过程中,若随机T次仍没找到,算法就会停止,从而保证了算法运行时间的可行性。

12.4 嵌入式选择与正则化
前面提到了的两种特征选择方法:过滤式中特征选择与后续学习器完全分离,包裹式则是使用学习器作为特征选择的评价准则;嵌入式是一种将特征选择与学习器训练完全融合的特征选择方法,即将特征选择融入学习器的优化过程中。在之前《经验风险与结构风险》中已经提到:经验风险指的是模型与训练数据的契合度,结构风险则是模型的复杂程度,机器学习的核心任务就是:在模型简单的基础上保证模型的契合度。例如:岭回归就是加上了L2范数的最小二乘法,有效地解决了奇异矩阵、过拟合等诸多问题,下面的嵌入式特征选择则是在损失函数后加上了L1范数。

L1范数美名又约Lasso Regularization,指的是向量中每个元素的绝对值之和,这样在优化目标函数的过程中,就会使得w尽可能地小,在一定程度上起到了防止过拟合的作用,同时与L2范数(Ridge Regularization )不同的是,L1范数会使得部分w变为0, 从而达到了特征选择的效果。
总的来说:L1范数会趋向产生少量的特征,其他特征的权值都是0;L2会选择更多的特征,这些特征的权值都会接近于0。这样L1范数在特征选择上就十分有用,而L2范数则具备较强的控制过拟合能力。可以从下面两个方面来理解:
(1)下降速度:L1范数按照绝对值函数来下降,L2范数按照二次函数来下降。因此在0附近,L1范数的下降速度大于L2范数,故L1范数能很快地下降到0,而L2范数在0附近的下降速度非常慢,因此较大可能收敛在0的附近。

(2)空间限制:L1范数与L2范数都试图在最小化损失函数的同时,让权值W也尽可能地小。我们可以将原优化问题看做为下面的问题,即让后面的规则则都小于某个阈值。这样从图中可以看出:L1范数相比L2范数更容易得到稀疏解。


12.5 稀疏表示与字典学习
当样本数据是一个稀疏矩阵时,对学习任务来说会有不少的好处,例如很多问题变得线性可分,储存更为高效等。这便是稀疏表示与字典学习的基本出发点。稀疏矩阵即矩阵的每一行/列中都包含了大量的零元素,且这些零元素没有出现在同一行/列,对于一个给定的稠密矩阵,若我们能通过某种方法找到其合适的稀疏表示,则可以使得学习任务更加简单高效,我们称之为稀疏编码(sparse coding)或字典学习(dictionary learning)。
给定一个数据集,字典学习/稀疏编码指的便是通过一个字典将原数据转化为稀疏表示,因此最终的目标就是求得字典矩阵B及稀疏表示α,书中使用变量交替优化的策略能较好地求得解,深感陷进去短时间无法自拔,故先不进行深入...

12.6 压缩感知
压缩感知在前些年也是风风火火,与特征选择、稀疏表示不同的是:它关注的是通过欠采样信息来恢复全部信息。在实际问题中,为了方便传输和存储,我们一般将数字信息进行压缩,这样就有可能损失部分信息,如何根据已有的信息来重构出全部信号,这便是压缩感知的来历,压缩感知的前提是已知的信息具有稀疏表示。下面是关于压缩感知的一些背景:

十三. 计算学习理论
计算学习理论(computational learning theory)是通过“计算”来研究机器学习的理论,简而言之,其目的是分析学习任务的本质,例如:在什么条件下可进行有效的学习,需要多少训练样本能获得较好的精度等,从而为机器学习算法提供理论保证。
首先我们回归初心,再来谈谈经验误差和泛化误差。假设给定训练集D,其中所有的训练样本都服从一个未知的分布T,且它们都是在总体分布T中独立采样得到,即独立同分布(independent and identically distributed,i.i.d.),在《贝叶斯分类器》中我们已经提到:独立同分布是很多统计学习算法的基础假设,例如最大似然法,贝叶斯分类器,高斯混合聚类等,简单来理解独立同分布:每个样本都是从总体分布中独立采样得到,而没有拖泥带水。例如现在要进行问卷调查,要从总体人群中随机采样,看到一个美女你高兴地走过去,结果她男票突然冒了出来,说道:you jump,i jump,于是你本来只想调查一个人结果被强行撒了一把狗粮得到两份问卷,这样这两份问卷就不能称为独立同分布了,因为它们的出现具有强相关性。
回归正题,泛化误差指的是学习器在总体上的预测误差,经验误差则是学习器在某个特定数据集D上的预测误差。在实际问题中,往往我们并不能得到总体且数据集D是通过独立同分布采样得到的,因此我们常常使用经验误差作为泛化误差的近似。

13.1 PAC学习
在高中课本中,我们将函数定义为:从自变量到因变量的一种映射;对于机器学习算法,学习器也正是为了寻找合适的映射规则,即如何从条件属性得到目标属性。从样本空间到标记空间存在着很多的映射,我们将每个映射称之为概念(concept),定义:
若概念c对任何样本x满足c(x)=y,则称c为目标概念,即最理想的映射,所有的目标概念构成的集合称为**“概念类”; 给定学习算法,它所有可能映射/概念的集合称为“假设空间”,其中单个的概念称为“假设”(hypothesis); 若一个算法的假设空间包含目标概念,则称该数据集对该算法是可分**(separable)的,亦称一致(consistent)的; 若一个算法的假设空间不包含目标概念,则称该数据集对该算法是不可分(non-separable)的,或称不一致(non-consistent)的。
举个简单的例子:对于非线性分布的数据集,若使用一个线性分类器,则该线性分类器对应的假设空间就是空间中所有可能的超平面,显然假设空间不包含该数据集的目标概念,所以称数据集对该学习器是不可分的。给定一个数据集D,我们希望模型学得的假设h尽可能地与目标概念一致,这便是概率近似正确 (Probably Approximately Correct,简称PAC)的来源,即以较大的概率学得模型满足误差的预设上限。




上述关于PAC的几个定义层层相扣:定义12.1表达的是对于某种学习算法,如果能以一个置信度学得假设满足泛化误差的预设上限,则称该算法能PAC辨识概念类,即该算法的输出假设已经十分地逼近目标概念。定义12.2则将样本数量考虑进来,当样本超过一定数量时,学习算法总是能PAC辨识概念类,则称概念类为PAC可学习的。定义12.3将学习器运行时间也考虑进来,若运行时间为多项式时间,则称PAC学习算法。
显然,PAC学习中的一个关键因素就是假设空间的复杂度,对于某个学习算法,若假设空间越大,则其中包含目标概念的可能性也越大,但同时找到某个具体概念的难度也越大,一般假设空间分为有限假设空间与无限假设空间。
13.2 有限假设空间
13.2.1 可分情形
可分或一致的情形指的是:目标概念包含在算法的假设空间中。对于目标概念,在训练集D中的经验误差一定为0,因此首先我们可以想到的是:不断地剔除那些出现预测错误的假设,直到找到经验误差为0的假设即为目标概念。但由于样本集有限,可能会出现多个假设在D上的经验误差都为0,因此问题转化为:需要多大规模的数据集D才能让学习算法以置信度的概率从这些经验误差都为0的假设中找到目标概念的有效近似。

通过上式可以得知:对于可分情形的有限假设空间,目标概念都是PAC可学习的,即当样本数量满足上述条件之后,在与训练集一致的假设中总是可以在1-σ概率下找到目标概念的有效近似。
13.2.2 不可分情形
不可分或不一致的情形指的是:目标概念不存在于假设空间中,这时我们就不能像可分情形时那样从假设空间中寻找目标概念的近似。但当假设空间给定时,必然存一个假设的泛化误差最小,若能找出此假设的有效近似也不失为一个好的目标,这便是不可知学习(agnostic learning)的来源。

这时候便要用到Hoeffding不等式:

对于假设空间中的所有假设,出现泛化误差与经验误差之差大于e的概率和为:

因此,可令不等式的右边小于(等于)σ,便可以求出满足泛化误差与经验误差相差小于e所需的最少样本数,同时也可以求出泛化误差界。

13.3 VC维
现实中的学习任务通常都是无限假设空间,例如d维实数域空间中所有的超平面等,因此要对此种情形进行可学习研究,需要度量假设空间的复杂度。这便是VC维(Vapnik-Chervonenkis dimension)的来源。在介绍VC维之前,需要引入两个概念:
增长函数:对于给定数据集D,假设空间中的每个假设都能对数据集的样本赋予标记,因此一个假设对应着一种打标结果,不同假设对D的打标结果可能是相同的,也可能是不同的。随着样本数量m的增大,假设空间对样本集D的打标结果也会增多,增长函数则表示假设空间对m个样本的数据集D打标的最大可能结果数,因此增长函数描述了假设空间的表示能力与复杂度。
打散:例如对二分类问题来说,m个样本最多有2^m个可能结果,每种可能结果称为一种**“对分”**,若假设空间能实现数据集D的所有对分,则称数据集能被该假设空间打散。
因此尽管假设空间是无限的,但它对特定数据集打标的不同结果数是有限的,假设空间的VC维正是它能打散的最大数据集大小。通常这样来计算假设空间的VC维:若存在大小为d的数据集能被假设空间打散,但不存在任何大小为d+1的数据集能被假设空间打散,则其VC维为d。

同时书中给出了假设空间VC维与增长函数的两个关系:

直观来理解(1)式也十分容易: 首先假设空间的VC维是d,说明当m<=d时,增长函数与2^m相等,例如:当m=d时,右边的组合数求和刚好等于2^d;而当m=d+1时,右边等于2^(d+1)-1,十分符合VC维的定义,同时也可以使用数学归纳法证明;(2)式则是由(1)式直接推导得出。
在有限假设空间中,根据Hoeffding不等式便可以推导得出学习算法的泛化误差界;但在无限假设空间中,由于假设空间的大小无法计算,只能通过增长函数来描述其复杂度,因此无限假设空间中的泛化误差界需要引入增长函数。


上式给出了基于VC维的泛化误差界,同时也可以计算出满足条件需要的样本数(样本复杂度)。若学习算法满足经验风险最小化原则(ERM),即学习算法的输出假设h在数据集D上的经验误差最小,可证明:任何VC维有限的假设空间都是(不可知)PAC可学习的,换而言之:若假设空间的最小泛化误差为0即目标概念包含在假设空间中,则是PAC可学习,若最小泛化误差不为0,则称为不可知PAC可学习。
13.4 稳定性
稳定性考察的是当算法的输入发生变化时,输出是否会随之发生较大的变化,输入的数据集D有以下两种变化:

若对数据集中的任何样本z,满足:

即原学习器和剔除一个样本后生成的学习器对z的损失之差保持β稳定,称学习器关于损失函数满足β-均匀稳定性。同时若损失函数有上界,即原学习器对任何样本的损失函数不超过M,则有如下定理:

事实上,若学习算法符合经验风险最小化原则(ERM)且满足β-均匀稳定性,则假设空间是可学习的。稳定性通过损失函数与假设空间的可学习联系在了一起,区别在于:假设空间关注的是经验误差与泛化误差,需要考虑到所有可能的假设;而稳定性只关注当前的输出假设。
十四. 半监督学习
前面我们一直围绕的都是监督学习与无监督学习,监督学习指的是训练样本包含标记信息的学习任务,例如:常见的分类与回归算法;无监督学习则是训练样本不包含标记信息的学习任务,例如:聚类算法。在实际生活中,常常会出现一部分样本有标记和较多样本无标记的情形,例如:做网页推荐时需要让用户标记出感兴趣的网页,但是少有用户愿意花时间来提供标记。若直接丢弃掉无标记样本集,使用传统的监督学习方法,常常会由于训练样本的不充足,使得其刻画总体分布的能力减弱,从而影响了学习器泛化性能。那如何利用未标记的样本数据呢?
一种简单的做法是通过专家知识对这些未标记的样本进行打标,但随之而来的就是巨大的人力耗费。若我们先使用有标记的样本数据集训练出一个学习器,再基于该学习器对未标记的样本进行预测,从中挑选出不确定性高或分类置信度低的样本来咨询专家并进行打标,最后使用扩充后的训练集重新训练学习器,这样便能大幅度降低标记成本,这便是主动学习(active learning),其目标是使用尽量少的/有价值的咨询来获得更好的性能。
显然,主动学习需要与外界进行交互/查询/打标,其本质上仍然属于一种监督学习。事实上,无标记样本虽未包含标记信息,但它们与有标记样本一样都是从总体中独立同分布采样得到,因此它们所包含的数据分布信息对学习器的训练大有裨益。如何让学习过程不依赖外界的咨询交互,自动利用未标记样本所包含的分布信息的方法便是半监督学习(semi-supervised learning),即训练集同时包含有标记样本数据和未标记样本数据。

此外,半监督学习还可以进一步划分为纯半监督学习和直推学习,两者的区别在于:前者假定训练数据集中的未标记数据并非待预测数据,而后者假定学习过程中的未标记数据就是待预测数据。主动学习、纯半监督学习以及直推学习三者的概念如下图所示:

14.1 生成式方法
生成式方法(generative methods)是基于生成式模型的方法,即先对联合分布P(x,c)建模,从而进一步求解 P(c | x),此类方法假定样本数据服从一个潜在的分布,因此需要充分可靠的先验知识。例如:前面已经接触到的贝叶斯分类器与高斯混合聚类,都属于生成式模型。现假定总体是一个高斯混合分布,即由多个高斯分布组合形成,从而一个子高斯分布就代表一个类簇(类别)。高斯混合分布的概率密度函数如下所示:

不失一般性,假设类簇与真实的类别按照顺序一一对应,即第i个类簇对应第i个高斯混合成分。与高斯混合聚类类似地,这里的主要任务也是估计出各个高斯混合成分的参数以及混合系数,不同的是:对于有标记样本,不再是可能属于每一个类簇,而是只能属于真实类标对应的特定类簇。

直观上来看,基于半监督的高斯混合模型有机地整合了贝叶斯分类器与高斯混合聚类的核心思想,有效地利用了未标记样本数据隐含的分布信息,从而使得参数的估计更加准确。同样地,这里也要召唤出之前的EM大法进行求解,首先对各个高斯混合成分的参数及混合系数进行随机初始化,计算出各个PM(即γji,第i个样本属于j类,有标记样本则直接属于特定类),再最大化似然函数(即LL(D)分别对α、u和∑求偏导 ),对参数进行迭代更新。

当参数迭代更新收敛后,对于待预测样本x,便可以像贝叶斯分类器那样计算出样本属于每个类簇的后验概率,接着找出概率最大的即可:

可以看出:基于生成式模型的方法十分依赖于对潜在数据分布的假设,即假设的分布要能和真实分布相吻合,否则利用未标记的样本数据反倒会在错误的道路上渐行渐远,从而降低学习器的泛化性能。因此,此类方法要求极强的领域知识和掐指观天的本领。
14.2 半监督SVM
监督学习中的SVM试图找到一个划分超平面,使得两侧支持向量之间的间隔最大,即“最大划分间隔”思想。对于半监督学习,S3VM则考虑超平面需穿过数据低密度的区域。TSVM是半监督支持向量机中的最著名代表,其核心思想是:尝试为未标记样本找到合适的标记指派,使得超平面划分后的间隔最大化。TSVM采用局部搜索的策略来进行迭代求解,即首先使用有标记样本集训练出一个初始SVM,接着使用该学习器对未标记样本进行打标,这样所有样本都有了标记,并基于这些有标记的样本重新训练SVM,之后再寻找易出错样本不断调整。整个算法流程如下所示:


14.3 基于分歧的方法
基于分歧的方法通过多个学习器之间的**分歧(disagreement)/多样性(diversity)**来利用未标记样本数据,协同训练就是其中的一种经典方法。协同训练最初是针对于多视图(multi-view)数据而设计的,多视图数据指的是样本对象具有多个属性集,每个属性集则对应一个试图。例如:电影数据中就包含画面类属性和声音类属性,这样画面类属性的集合就对应着一个视图。首先引入两个关于视图的重要性质:
相容性:即使用单个视图数据训练出的学习器的输出空间是一致的。例如都是{好,坏}、{+1,-1}等。 互补性:即不同视图所提供的信息是互补/相辅相成的,实质上这里体现的就是集成学习的思想。
协同训练正是很好地利用了多视图数据的“相容互补性”,其基本的思想是:首先基于有标记样本数据在每个视图上都训练一个初始分类器,然后让每个分类器去挑选分类置信度最高的样本并赋予标记,并将带有伪标记的样本数据传给另一个分类器去学习,从而你依我侬/共同进步。

14.4 半监督聚类
前面提到的几种方法都是借助无标记样本数据来辅助监督学习的训练过程,从而使得学习更加充分/泛化性能得到提升;半监督聚类则是借助已有的监督信息来辅助聚类的过程。一般而言,监督信息大致有两种类型:
必连与勿连约束:必连指的是两个样本必须在同一个类簇,勿连则是必不在同一个类簇。 标记信息:少量的样本带有真实的标记。
下面主要介绍两种基于半监督的K-Means聚类算法:第一种是数据集包含一些必连与勿连关系,另外一种则是包含少量带有标记的样本。两种算法的基本思想都十分的简单:对于带有约束关系的k-均值算法,在迭代过程中对每个样本划分类簇时,需要检测当前划分是否满足约束关系,若不满足则会将该样本划分到距离次小对应的类簇中,再继续检测是否满足约束关系,直到完成所有样本的划分。算法流程如下图所示:

对于带有少量标记样本的k-均值算法,则可以利用这些有标记样本进行类中心的指定,同时在对样本进行划分时,不需要改变这些有标记样本的簇隶属关系,直接将其划分到对应类簇即可。算法流程如下所示:

十五. 概率图模型
现在再来谈谈机器学习的核心价值观,可以更通俗地理解为:根据一些已观察到的证据来推断未知,更具哲学性地可以阐述为:未来的发展总是遵循着历史的规律。其中基于概率的模型将学习任务归结为计算变量的概率分布,正如之前已经提到的:生成式模型先对联合分布进行建模,从而再来求解后验概率,例如:贝叶斯分类器先对联合分布进行最大似然估计,从而便可以计算类条件概率;判别式模型则是直接对条件分布进行建模。
概率图模型(probabilistic graphical model)是一类用图结构来表达各属性之间相关关系的概率模型,一般而言:图中的一个结点表示一个或一组随机变量,结点之间的边则表示变量间的相关关系,从而形成了一张“变量关系图”。若使用有向的边来表达变量之间的依赖关系,这样的有向关系图称为贝叶斯网(Bayesian nerwork)或有向图模型;若使用无向边,则称为马尔可夫网(Markov network)或无向图模型。
15.1 隐马尔可夫模型(HMM)
隐马尔可夫模型(Hidden Markov Model,简称HMM)是结构最简单的一种贝叶斯网,在语音识别与自然语言处理领域上有着广泛的应用。HMM中的变量分为两组:状态变量与观测变量,其中状态变量一般是未知的,因此又称为“隐变量”,观测变量则是已知的输出值。在隐马尔可夫模型中,变量之间的依赖关系遵循如下两个规则:
1. 观测变量的取值仅依赖于状态变量; 2. 下一个状态的取值仅依赖于当前状态,通俗来讲:现在决定未来,未来与过去无关,这就是著名的马尔可夫性。

基于上述变量之间的依赖关系,我们很容易写出隐马尔可夫模型中所有变量的联合概率分布:

易知:欲确定一个HMM模型需要以下三组参数:

当确定了一个HMM模型的三个参数后,便按照下面的规则来生成观测值序列:

在实际应用中,HMM模型的发力点主要体现在下述三个问题上:

15.1.1 HMM评估问题
HMM评估问题指的是:给定了模型的三个参数与观测值序列,求该观测值序列出现的概率。例如:对于赌场问题,便可以依据骰子掷出的结果序列来计算该结果序列出现的可能性,若小概率的事件发生了则可认为赌场的骰子有作弊的可能。解决该问题使用的是前向算法,即步步为营,自底向上的方式逐步增加序列的长度,直到获得目标概率值。在前向算法中,定义了一个前向变量,即给定观察值序列且t时刻的状态为Si的概率:

基于前向变量,很容易得到该问题的递推关系及终止条件:

因此可使用动态规划法,从最小的子问题开始,通过填表格的形式一步一步计算出目标结果。
15.1.2 HMM解码问题
HMM解码问题指的是:给定了模型的三个参数与观测值序列,求可能性最大的状态序列。例如:在语音识别问题中,人说话形成的数字信号对应着观测值序列,对应的具体文字则是状态序列,从数字信号转化为文字正是对应着根据观测值序列推断最有可能的状态值序列。解决该问题使用的是Viterbi算法,与前向算法十分类似地,Viterbi算法定义了一个Viterbi变量,也是采用动态规划的方法,自底向上逐步求解。

15.1.3 HMM学习问题
HMM学习问题指的是:给定观测值序列,如何调整模型的参数使得该序列出现的概率最大。这便转化成了机器学习问题,即从给定的观测值序列中学习出一个HMM模型,该问题正是EM算法的经典案例之一。其思想也十分简单:对于给定的观测值序列,如果我们能够按照该序列潜在的规律来调整模型的三个参数,则可以使得该序列出现的可能性最大。假设状态值序列也已知,则很容易计算出与该序列最契合的模型参数:

但一般状态值序列都是不可观测的,且即使给定观测值序列与模型参数,状态序列仍然遭遇组合爆炸。因此上面这种简单的统计方法就行不通了,若将状态值序列看作为隐变量,这时便可以考虑使用EM算法来对该问题进行求解:
【1】首先对HMM模型的三个参数进行随机初始化; 【2】根据模型的参数与观测值序列,计算t时刻状态为i且t+1时刻状态为j的概率以及t时刻状态为i的概率。

【3】接着便可以对模型的三个参数进行重新估计:

【4】重复步骤2-3,直至三个参数值收敛,便得到了最终的HMM模型。
15.2 马尔可夫随机场(MRF)
马尔可夫随机场(Markov Random Field)是一种典型的马尔可夫网,即使用无向边来表达变量间的依赖关系。在马尔可夫随机场中,对于关系图中的一个子集,若任意两结点间都有边连接,则称该子集为一个团;若再加一个结点便不能形成团,则称该子集为极大团。MRF使用势函数来定义多个变量的概率分布函数,其中每个(极大)团对应一个势函数,一般团中的变量关系也体现在它所对应的极大团中,因此常常基于极大团来定义变量的联合概率分布函数。具体而言,若所有变量构成的极大团的集合为C,则MRF的联合概率函数可以定义为:

对于条件独立性,马尔可夫随机场通过分离集来实现条件独立,若A结点集必须经过C结点集才能到达B结点集,则称C为分离集。书上给出了一个简单情形下的条件独立证明过程,十分贴切易懂,此处不再展开。基于分离集的概念,得到了MRF的三个性质:
全局马尔可夫性:给定两个变量子集的分离集,则这两个变量子集条件独立。 局部马尔可夫性:给定某变量的邻接变量,则该变量与其它变量条件独立。 成对马尔可夫性:给定所有其他变量,两个非邻接变量条件独立。

对于MRF中的势函数,势函数主要用于描述团中变量之间的相关关系,且要求为非负函数,直观来看:势函数需要在偏好的变量取值上函数值较大,例如:若x1与x2成正相关,则需要将这种关系反映在势函数的函数值中。一般我们常使用指数函数来定义势函数:

15.3 条件随机场(CRF)
前面所讲到的隐马尔可夫模型和马尔可夫随机场都属于生成式模型,即对联合概率进行建模,条件随机场则是对条件分布进行建模。CRF试图在给定观测值序列后,对状态序列的概率分布进行建模,即P(y | x)。直观上看:CRF与HMM的解码问题十分类似,都是在给定观测值序列后,研究状态序列可能的取值。CRF可以有多种结构,只需保证状态序列满足马尔可夫性即可,一般我们常使用的是链式条件随机场:

与马尔可夫随机场定义联合概率类似地,CRF也通过团以及势函数的概念来定义条件概率P(y | x)。在给定观测值序列的条件下,链式条件随机场主要包含两种团结构:单个状态团及相邻状态团,通过引入两类特征函数便可以定义出目标条件概率:

以词性标注为例,如何判断给出的一个标注序列靠谱不靠谱呢?转移特征函数主要判定两个相邻的标注是否合理,例如:动词+动词显然语法不通;状态特征函数则判定观测值与对应的标注是否合理,例如: ly结尾的词-->副词较合理。因此我们可以定义一个特征函数集合,用这个特征函数集合来为一个标注序列打分,并据此选出最靠谱的标注序列。也就是说,每一个特征函数(对应一种规则)都可以用来为一个标注序列评分,把集合中所有特征函数对同一个标注序列的评分综合起来,就是这个标注序列最终的评分值。可以看出:特征函数是一些经验的特性。
15.4 学习与推断
对于生成式模型,通常我们都是先对变量的联合概率分布进行建模,接着再求出目标变量的边际分布(marginal distribution),那如何从联合概率得到边际分布呢?这便是学习与推断。下面主要介绍两种精确推断的方法:变量消去与信念传播。
15.4.1 变量消去
变量消去利用条件独立性来消减计算目标概率值所需的计算量,它通过运用乘法与加法的分配率,将对变量的积的求和问题转化为对部分变量交替进行求积与求和的问题,从而将每次的运算控制在局部,达到简化运算的目的。

15.4.2 信念传播
若将变量求和操作看作是一种消息的传递过程,信念传播可以理解成:一个节点在接收到所有其它节点的消息后才向另一个节点发送消息,同时当前节点的边际概率正比于他所接收的消息的乘积:

因此只需要经过下面两个步骤,便可以完成所有的消息传递过程。利用动态规划法的思想记录传递过程中的所有消息,当计算某个结点的边际概率分布时,只需直接取出传到该结点的消息即可,从而避免了计算多个边际分布时的冗余计算问题。
1.指定一个根节点,从所有的叶节点开始向根节点传递消息,直到根节点收到所有邻接结点的消息**(从叶到根); 2.从根节点开始向叶节点传递消息,直到所有叶节点均收到消息(从根到叶)**。

15.5 LDA话题模型
话题模型主要用于处理文本类数据,其中隐狄利克雷分配模型(Latent Dirichlet Allocation,简称LDA)是话题模型的杰出代表。在话题模型中,有以下几个基本概念:词(word)、文档(document)、话题(topic)。
词:最基本的离散单元; 文档:由一组词组成,词在文档中不计顺序; 话题:由一组特定的词组成,这组词具有较强的相关关系。
在现实任务中,一般我们可以得出一个文档的词频分布,但不知道该文档对应着哪些话题,LDA话题模型正是为了解决这个问题。具体来说:LDA认为每篇文档包含多个话题,且其中每一个词都对应着一个话题。因此可以假设文档是通过如下方式生成:

这样一个文档中的所有词都可以认为是通过话题模型来生成的,当已知一个文档的词频分布后(即一个N维向量,N为词库大小),则可以认为:每一个词频元素都对应着一个话题,而话题对应的词频分布则影响着该词频元素的大小。因此很容易写出LDA模型对应的联合概率函数:

从上图可以看出,LDA的三个表示层被三种颜色表示出来:
corpus-level(红色): α和β表示语料级别的参数,也就是每个文档都一样,因此生成过程只采样一次。 document-level(橙色): θ是文档级别的变量,每个文档对应一个θ。 word-level(绿色): z和w都是单词级别变量,z由θ生成,w由z和β共同生成,一个单词w对应一个主题z。
通过上面对LDA生成模型的讨论,可以知道LDA模型主要是想从给定的输入语料中学习训练出两个控制参数α和β,当学习出了这两个控制参数就确定了模型,便可以用来生成文档。其中α和β分别对应以下各个信息:
α:分布p(θ)需要一个向量参数,即Dirichlet分布的参数,用于生成一个主题θ向量; β:各个主题对应的单词概率分布矩阵p(w|z)。
把w当做观察变量,θ和z当做隐藏变量,就可以通过EM算法学习出α和β,求解过程中遇到后验概率p(θ,z|w)无法直接求解,需要找一个似然函数下界来近似求解,原作者使用基于分解(factorization)假设的变分法(varialtional inference)进行计算,用到了EM算法。每次E-step输入α和β,计算似然函数,M-step最大化这个似然函数,算出α和β,不断迭代直到收敛。
十六. 强化学习
强化学习(Reinforcement Learning,简称RL)是机器学习的一个重要分支,前段时间人机大战的主角AlphaGo正是以强化学习为核心技术。在强化学习中,包含两种基本的元素:状态与动作,在某个状态下执行某种动作,这便是一种策略,学习器要做的就是通过不断地探索学习,从而获得一个好的策略。例如:在围棋中,一种落棋的局面就是一种状态,若能知道每种局面下的最优落子动作,那就攻无不克/百战不殆了~
若将状态看作为属性,动作看作为标记,易知:监督学习和强化学习都是在试图寻找一个映射,从已知属性/状态推断出标记/动作,这样强化学习中的策略相当于监督学习中的分类/回归器。但在实际问题中,强化学习并没有监督学习那样的标记信息,通常都是在尝试动作后才能获得结果,因此强化学习是通过反馈的结果信息不断调整之前的策略,从而算法能够学习到:在什么样的状态下选择什么样的动作可以获得最好的结果。
16.1 基本要素
强化学习任务通常使用马尔可夫决策过程(Markov Decision Process,简称MDP)来描述,具体而言:机器处在一个环境中,每个状态为机器对当前环境的感知;机器只能通过动作来影响环境,当机器执行一个动作后,会使得环境按某种概率转移到另一个状态;同时,环境会根据潜在的奖赏函数反馈给机器一个奖赏。综合而言,强化学习主要包含四个要素:状态、动作、转移概率以及奖赏函数。
状态(X):机器对环境的感知,所有可能的状态称为状态空间; 动作(A):机器所采取的动作,所有能采取的动作构成动作空间; 转移概率(P):当执行某个动作后,当前状态会以某种概率转移到另一个状态; 奖赏函数(R):在状态转移的同时,环境给反馈给机器一个奖赏。

因此,强化学习的主要任务就是通过在环境中不断地尝试,根据尝试获得的反馈信息调整策略,最终生成一个较好的策略π,机器根据这个策略便能知道在什么状态下应该执行什么动作。常见的策略表示方法有以下两种:
确定性策略:π(x)=a,即在状态x下执行a动作; 随机性策略:P=π(x,a),即在状态x下执行a动作的概率。
一个策略的优劣取决于长期执行这一策略后的累积奖赏,换句话说:可以使用累积奖赏来评估策略的好坏,最优策略则表示在初始状态下一直执行该策略后,最后的累积奖赏值最高。长期累积奖赏通常使用下述两种计算方法:

16.2 K摇摆赌博机
首先我们考虑强化学习最简单的情形:仅考虑一步操作,即在状态x下只需执行一次动作a便能观察到奖赏结果。易知:欲最大化单步奖赏,我们需要知道每个动作带来的期望奖赏值,这样便能选择奖赏值最大的动作来执行。若每个动作的奖赏值为确定值,则只需要将每个动作尝试一遍即可,但大多数情形下,一个动作的奖赏值来源于一个概率分布,因此需要进行多次的尝试。
单步强化学习实质上是K-摇臂赌博机(K-armed bandit)的原型,一般我们尝试动作的次数是有限的,那如何利用有限的次数进行有效地探索呢?这里有两种基本的想法:
仅探索法:将尝试的机会平均分给每一个动作,即轮流执行,最终将每个动作的平均奖赏作为期望奖赏的近似值。 仅利用法:将尝试的机会分给当前平均奖赏值最大的动作,隐含着让一部分人先富起来的思想。
可以看出:上述两种方法是相互矛盾的,仅探索法能较好地估算每个动作的期望奖赏,但是没能根据当前的反馈结果调整尝试策略;仅利用法在每次尝试之后都更新尝试策略,符合强化学习的思(tao)维(lu),但容易找不到最优动作。因此需要在这两者之间进行折中。
16.2.1 ε-贪心
ε-贪心法基于一个概率来对探索和利用进行折中,具体而言:在每次尝试时,以ε的概率进行探索,即以均匀概率随机选择一个动作;以1-ε的概率进行利用,即选择当前最优的动作。ε-贪心法只需记录每个动作的当前平均奖赏值与被选中的次数,便可以增量式更新。

16.2.2 Softmax
Softmax算法则基于当前每个动作的平均奖赏值来对探索和利用进行折中,Softmax函数将一组值转化为一组概率,值越大对应的概率也越高,因此当前平均奖赏值越高的动作被选中的几率也越大。Softmax函数如下所示:

16.3 有模型学习
若学习任务中的四个要素都已知,即状态空间、动作空间、转移概率以及奖赏函数都已经给出,这样的情形称为“有模型学习”。假设状态空间和动作空间均为有限,即均为离散值,这样我们不用通过尝试便可以对某个策略进行评估。
16.3.1 策略评估
前面提到:在模型已知的前提下,我们可以对任意策略的进行评估(后续会给出演算过程)。一般常使用以下两种值函数来评估某个策略的优劣:
状态值函数(V):V(x),即从状态x出发,使用π策略所带来的累积奖赏; 状态-动作值函数(Q):Q(x,a),即从状态x出发,执行动作a后再使用π策略所带来的累积奖赏。
根据累积奖赏的定义,我们可以引入T步累积奖赏与r折扣累积奖赏:

由于MDP具有马尔可夫性,即现在决定未来,将来和过去无关,我们很容易找到值函数的递归关系:

类似地,对于r折扣累积奖赏可以得到:

易知:当模型已知时,策略的评估问题转化为一种动态规划问题,即以填表格的形式自底向上,先求解每个状态的单步累积奖赏,再求解每个状态的两步累积奖赏,一直迭代逐步求解出每个状态的T步累积奖赏。算法流程如下所示:

对于状态-动作值函数,只需通过简单的转化便可得到:

16.3.2 策略改进
理想的策略应能使得每个状态的累积奖赏之和最大,简单来理解就是:不管处于什么状态,只要通过该策略执行动作,总能得到较好的结果。因此对于给定的某个策略,我们需要对其进行改进,从而得到最优的值函数。

最优Bellman等式改进策略的方式为:将策略选择的动作改为当前最优的动作,而不是像之前那样对每种可能的动作进行求和。易知:选择当前最优动作相当于将所有的概率都赋给累积奖赏值最大的动作,因此每次改进都会使得值函数单调递增。

将策略评估与策略改进结合起来,我们便得到了生成最优策略的方法:先给定一个随机策略,现对该策略进行评估,然后再改进,接着再评估/改进一直到策略收敛、不再发生改变。这便是策略迭代算法,算法流程如下所示:

可以看出:策略迭代法在每次改进策略后都要对策略进行重新评估,因此比较耗时。若从最优化值函数的角度出发,即先迭代得到最优的值函数,再来计算如何改变策略,这便是值迭代算法,算法流程如下所示:

16.4 蒙特卡罗强化学习
在现实的强化学习任务中,环境的转移函数与奖赏函数往往很难得知,因此我们需要考虑在不依赖于环境参数的条件下建立强化学习模型,这便是免模型学习。蒙特卡罗强化学习便是其中的一种经典方法。
由于模型参数未知,状态值函数不能像之前那样进行全概率展开,从而运用动态规划法求解。一种直接的方法便是通过采样来对策略进行评估/估算其值函数,蒙特卡罗强化学习正是基于采样来估计状态-动作值函数:对采样轨迹中的每一对状态-动作,记录其后的奖赏值之和,作为该状态-动作的一次累积奖赏,通过多次采样后,使用累积奖赏的平均作为状态-动作值的估计,并引入ε-贪心策略保证采样的多样性。

在上面的算法流程中,被评估和被改进的都是同一个策略,因此称为同策略蒙特卡罗强化学习算法。引入ε-贪心仅是为了便于采样评估,而在使用策略时并不需要ε-贪心,那能否仅在评估时使用ε-贪心策略,而在改进时使用原始策略呢?这便是异策略蒙特卡罗强化学习算法。

16.5 AlphaGo原理浅析
本篇一开始便提到强化学习是AlphaGo的核心技术之一,刚好借着这个东风将AlphaGo的工作原理了解一番。正如人类下棋那般“手下一步棋,心想三步棋”,Alphago也正是这个思想,当处于一个状态时,机器会暗地里进行多次的尝试/采样,并基于反馈回来的结果信息改进估值函数,从而最终通过增强版的估值函数来选择最优的落子动作。
其中便涉及到了三个主要的问题:(1)如何确定估值函数(2)如何进行采样(3)如何基于反馈信息改进估值函数,这正对应着AlphaGo的三大核心模块:深度学习、蒙特卡罗搜索树、强化学习。
1.深度学习(拟合估值函数)
由于围棋的状态空间巨大,像蒙特卡罗强化学习那样通过采样来确定值函数就行不通了。在围棋中,状态值函数可以看作为一种局面函数,状态-动作值函数可以看作一种策略函数,若我们能获得这两个估值函数,便可以根据这两个函数来完成:(1)衡量当前局面的价值;(2)选择当前最优的动作。那如何精确地估计这两个估值函数呢?这就用到了深度学习,通过大量的对弈数据自动学习出特征,从而拟合出估值函数。
2.蒙特卡罗搜索树(采样)
蒙特卡罗树是一种经典的搜索框架,它通过反复地采样模拟对局来探索状态空间。具体表现在:从当前状态开始,利用策略函数尽可能选择当前最优的动作,同时也引入随机性来减小估值错误带来的负面影响,从而模拟棋局运行,使得棋盘达到终局或一定步数后停止。

3.强化学习(调整估值函数)
在使用蒙特卡罗搜索树进行多次采样后,每次采样都会反馈后续的局面信息(利用局面函数进行评价),根据反馈回来的结果信息自动调整两个估值函数的参数,这便是强化学习的核心思想,最后基于改进后的策略函数选择出当前最优的落子动作。

熵、交叉熵和KL散度
熵(Entropy)的介绍 我们以天气预报为例子,进行熵的介绍.
-
假如只有 2 种天气,sunny 和 rainy ,那么明天对于每一种天气来说,各有 50% 的可能性.
-
此时气象部门告诉你明天是rainy,他其实减少了你的不确定信息.
-
所以,天气部门给了你 1 bit的有效信息(因为此时只有两种可能性).
-
假如只有8种天气,每一种天气出现是等可能的.
-
此时气象部门告诉你明天是 Rainy ,他其实减少了你的不确定信息,也就是告诉了你有效信息.
-
所以,天气部门给了你 3 bit的有效信息(因为8种状态需要 2^3=8 ,需要 3 bit来表示.
-
所以,有效信息的计算可以使用 log 来进行计算,计算过程如下
-
上面所有的情况都是等概率出现的,假设各种情况出现的概率不是相等的.
-
例如有75%的可能性是Sunny,25%的可能性是Rainy.
-
如果气象部门告诉你明天是Rainy
- 我们会使用概率的倒数, (概率越小,有效信息越多)
- 接着计算有效信息, ,log的等价计算)
- 因为和本来的概率相差比较大,所以获得的有效信息比较多(本来是 Rainy 的可能性小)
-
如果气象部门告诉你明天是 Sunny
- 同样计算此时的有效信息,
- 因为和本来的概率相差比较小,所以获得的信息比较少(本来是 Sunny 的可能性大)
-
从气象部门获得的信息的平均值(这个就是熵
- 简单解释: 有 75% 的可能性是Sunny ,得到晴天的有效信息是 0.41 ,所以是
-
于是我们得到了熵的计算公式.
- 熵是用来衡量获取的信息的平均值,没有获取什么信息量,则 Entropy 接近 0 .
- 下面是熵的计算公式
交叉熵(Cross-Entropy)的介绍 对于交叉熵的介绍,我们还是以天气预报作为例子来进行讲解.
- 交叉熵(Cross-Entropy)可以理解为平均的 message length ,即平均信息长度.
- 现在有 8 种天气,于是每一种天气我们可以使用 3 bit 来进行表示(000,001,010,011...)
- 此时 average message length = 3 ,所以此时Cross-Entropy
现在假设你住在一个 sunny region ,出现晴天的可能性比较大(即每一种天气不是等可能出现的),下图是每一种天气的概率.
我们来计算一下此时的熵(ntropy) ,计算的式子如下所示:
- 此时有效的信息是 2.23 bit.
- 所以再使用上面的编码方式(都使用 3 会有咒余.
- 也就是说我们每次发出 3 bit ,接收者有效信息为2.23 bit.
- 这时我们可以修改天气的 encode 的方式,可以给经常出现的天气比较小的 code 来进行表示,于是我们可以按照下图对每一种天气进行 encode .
此时的平均长度的计算如下所示(每一种天气的概率该天气code的长度): 此时的平均长度为 2.42 bit,可以看到比一开始的 3 bit有所减少.
如果我们使用相同的 code ,但是此时对应的天气的概率是不相同的,此时计算出的平均长度就会发生改变.此时每一种天气的概率如下图所示:
于是此时的信息的平均长度就是 4.58 bit ,比 Entropy 大很多 (如上图所示,我们给了概率很小的天 气的 code 也很小,概率很大的天气的 code 也很大,此时就会导致计算出的平均长度很大),下面是平均长度的计算的式子. 我们如何来理解我们给每一种天气的 code 呢,其实我们可以理解为这就是我们对每一种天气发生的可能性的预测,我们会给出现概率比较大的天气比较短的 code ,这里的概率是我们假设的,即我们有一个估计的概率,我们估计这个天气的概率比较大,所以给这个天气比较短的 code.
下图中可以表示出我们预测的q(predicted distribution)和真实分布p(true distribution).可以看到此时我们的预测概率 与真实分布 之间相差很大(此时计算出的交叉熵就会比较大)
关于上面 code 长度与概率的转换,我们可以这么来进行理解,对于概率为 的信息,他的有效信息为 .若此时 code 长度为 ,我们问概率为多少的信息的有效信息为 n ,即求解 ,则 ,所以我们就可以求出 长度与概率的转换.此时,我们就可以定义交叉熵(Cross-Entropy),这里会有两个变量,分别是p(真实的分布)和 q(预测的概率): 这个交叉熵公式的意思就是在计算消息的平均长度,我们可以这样来进行理解.
- 是将预测概率转换为 code 的长度(这里看上面 code 长度与概率的转换)
- 接着我们再将 code 的长度 乘上出现的概率 真实的概率
我们简单说明一下熵(Entropy),和交叉熵(Cross-Entropy)的性质:
- 如果预测结果是好的,,那么 p 和 q 的分布是相似的,此时 Cross-Entropy 与 Entropy 是相似 的.
- 如果 p 和 q 有很大的不同,那么 Cross-Entropy 会比 Entropy 大.
- 其中 Cross-Entropy 比 Entropy 大的部分,我们称为 relative entropy ,或是Kullback- Leibler Divergence(KL Divergence),这个就是KL-散度,我们会在后面进行详细的介 绍.
- 也就是说,三者的关系为:Cross-Entropy=Entropy+ KL Divergence
在进行分类问题的时候,我们通常会将 loss 函数设置为交叉熵(Cross-Entropy),其实现在来看这个也是很好理解,我们会有我们预测的概率 q 和实际的概率 p ,若 p 和 q 相似,则交叉熵小,若 p 和 q 不相似,则交叉熵大.
有一个要注意的是,我们通常在使用的时候会使用 10 为底的 log ,但是这个不影响 ,因为 ,我们可以通过公式进行转换.
在 PyTorch 中,CrossEntropyLoss 不是直接按照上面进行计算的,他是包含了 Softmax 的步骤的.关于在 PyTorch 中 CrossEntropyLoss 的实际计算: 详细介绍:PyTorch中交叉熵的计算CrossEntropyLoss
交叉熵损失函数
交叉熵损失函数(Cross-Entropy Loss Function)一般用于分类问题.假设样本的标签 𝑦 ∈ {1,⋯,𝐶} 为离散的类别,模型 的输出为类别标签的条件概率分布,即 并满足 我们可以用一个 维的one-hot向量 来表示样本标签.假设样本的标签为 ,那么标签向量只有第 维的值为 1 ,其余元素的值都为 0 .标签向量 可以看作样本标签的真实条件概率分布 ,即第 维(记为 ) 是类别为 的真实条件概率.假设样本的类别为 ,那么它属于第 类的概率为 1 ,属于其他类的概率为 0 . 对于两个概率分布,一般可以用交叉熵来衡量它们的差异.标签的真实分布 和模型预测分布 之间的交叉熵为 比如对于三分类问题,一个样本的标签向量为,模型预测的标签分布为,则它们的交叉熵为. 因为 为one-hot向量,上式也可以写为 其中 可以看作真实类别 的似然函数.因此,交叉熵损失函数也就是负对数似然函数(Negative Log-Likelihood).
交叉熵最小值证明
为什么当 与 的分布一致时, 有最小值:
证明如下: 因为 为定义域上的凸函数 根据琴生不等式有 其中
将 代入则有: 所以 当 时, 等号成立.
神经网络
书可参考邱锡鹏:神经网络与深度学习
一. 神经元
时刻思考各个激活函数的特点,如合理性?梯度爆炸?梯度消失?
1. Sigmoid型函数
Sigmoid型函数是指一类S型曲线函数,为两端饱和函数.常用的Sigmoid型函数有Logistic函数和Tanh函数.
>对于函数 ,若 时,其导数 ,则称其为左饱和.若 时,其导数 ,则称其为右饱和.当同时满足左、右饱和时,就称为两端饱和.
1.1 Logistic型函数
1.2 Tanh函数
Tanh函数可以看作放大并平移的Logistic函数,其值域是(−1,1)
下图给出了Logistic函数和Tanh函数的形状.Tanh函数的输出是零中心化的(Zero-Centered),而Logistic函数的输出恒大于0.非零中心化的输出会使得其后一层的神经元的输入发生偏置偏移(Bias Shift),并进一步使得梯度下降的收敛速度变慢.
2. ReLU函数
ReLU(Rectified Linear Unit,修正线性单元),也叫Rectifier函数,是目前深度神经网络中经常使用的激活函数.ReLU实际上是一个斜坡(ramp)函数,定义为 ReLU神经元训练时比较容易“死亡”,在训练时,如果参数在一次不恰当的更新后,第一个隐藏层中的某个ReLU神经元在所有的训练数据上都不能被激活,那么这个神经元自身参数的梯度永远都会是0,在以后的训练过程中永远不能被激活.这种现象称为死亡ReLU问题(DyingReLU Problem).故有以下变种
2.1 带泄露的ReLU
带泄露的ReLU(Leaky ReLU)在输入 时,保持一个很小的梯度 .这样当神经元非激活时也能有一个非零的梯度可以更新参数,避免永远不能被激活.
2.2 带参数的ReLU
带参数的ReLU(Parametric ReLU,PReLU)引入一个可学习的参数,不同神经元可以有不同的参数[He et al.,2015].对于第𝑖个神经元,其PReLU的定义为
2.3 ELU函数
ELU(Exponential Linear Unit,指数线性单元)是一个近似的零中心化的非线性函数,其定义为 其中𝛾 ≥ 0是一个超参数,决定𝑥 ≤ 0时的饱和曲线,并调整输出均值在0附近.
2.4 Softplus函数
Softplus函数可以看作Rectifier函数的平滑版本,其定义为
Softplus函数其导数刚好是Logistic函数.Softplus函数虽然也具有单侧抑制、宽兴奋边界的特性,却没有稀疏激活性.
3. Swish函数
Swish函数是一种自门控(Self-Gated)激活函数,定义为
其中为Logistic函数, 为可学习的参数或一个固定超参数. 可以看作一种软性的门控机制.当 接近于1时,门处于“开”状态,激活函数的输出近似于𝑥本身;当 接近于0时,门的状态为“关”,激活函数的输出近似于0
当 时,Swish函数变成线性函数 .当 时,Swish函数在 时近似线性,在 时近似饱和,同时具有一定的非单调性.当 时, 趋向于离散的0-1函数,Swish函数近似为ReLU函数.因此,Swish函数可以看作线性函数和ReLU函数之间的非线性插值函数,其程度由参数 控制.
4. GELU函数
TODO:GELU函数
5. Maxout单元
TODO:Maxout单元
二. 网络结构
1. 网络结构总述
目前为止,常用的神经网络有如下三种:
- 前馈网络:整个网络中的信息是朝一个方向传播,没有反向的信息传播,可以用一个有向无环路图表示.前馈网络包括全连接前馈网络和卷积神经网络等.前馈网络可以看作一个函数,通过简单非线性函数的多次复合,实现输入空间到输出空间的复杂映射.
- 记忆网络:也称为反馈网络,网络中的神经元不但可以接收其他神经元的信息,也可以接收自己的历史信息.和前馈网络相比,记忆网络中的神经元具有记忆功能,在不同的时刻具有不同的状态.记忆神经网络中的信息传播可以是单向或双向传递,因此可用一个有向循环图或无向图来表示.记忆网络包括循环神经网络、Hopfield网络、玻尔兹曼机、受限玻尔兹曼机等.记忆网络可以看作一个程序,具有更强的计算和记忆能力.为了增强记忆网络的记忆容量,可以引入外部记忆单元和读写机制,用来保存一些网络的中间状态,称为记忆增强神经网络(Memory Augmented NeuralNetwork,MANN),比如神经图灵机和记忆网络等.
- 图网络:实际应用中很多数据是图结构的数据,比如知识图谱、社交网络、分子(Molecular)网络等.图网络是定义在图结构数据上的神经网络.图中每个节点都由一个或一组神经元构成.节点之间的连接可以是有向的,也可以是无向的.每个节点可以收到来自相邻节点或自身的信息.图网络是前馈网络和记忆网络的泛化,包含很多不同的实现方式,比如图卷积网络(Graph Convolutional Network,GCN)、图注意力网络(Graph Attention Network,GAT)、消息传递神经网络(Message Passing Neural Network,MPNN)等.
三. 小批量梯度下降
小批量梯度下降法(Mini-BatchGradient Descent).
令 表示一个深度神经网络, 为网络参数,在使用小批量梯度下降进行优化时,每次选取 个训练样本 .第 次迭代时损失函数关于参数 的偏导数为 其中 为可微分的损失函数, 为批量大小(batch size).
前馈神经网络
一. 前馈神经网络
前馈神经网络(Feedforward Neural Network,FNN)是最早发明的简单人工神经网络.前馈神经网络也经常称为多层感知器(Multi-Layer Perceptron,MLP).
在前馈神经网络中,各神经元分别属于不同的层.每一层的神经元可以接收前一层神经元的信号,并产生信号输出到下一层.第 0 层称为输入层,最后一层称为输出层,其他中间层称为隐藏层.整个网络中无反馈,信号从输入层向输出层单向传播,可用一个有向无环图表示.
下面用到的记号:
- :神经网络的层数
- :第 层神经元的个数
- :第 层神经元的激活函数
- :第 层到第 层的权重矩阵
- :第 层神经元的净输入 净活性值
- :第 层神经元的输出 活性值
令 ,前馈神经网络通过不断迭代下面公式进行信息传播:
首先根据第 层神经元的活性值 ( Activation ) 计算出第 层神经元的净活性值 ( Net Activation ) ,然后经过一个激活函数得到第 层神经元的活性 值因此,我们也可以把每个神经层看作一个仿射变换 ( Affine Transformation ) 和一个非线性变换. 上述两式也可以合并写为: 或者
仿射变换:又称仿射映射,是指在几何中,一个向量空间进行一次线性变换并接上一个平移,变换为另一个向量空间.
这样, 前馈神经网络可以通过逐层的信息传递,得到网络最后的输出 .整个网络可以看作一个复合函数 ,将向量 作为第 1 层的输入 .将第 层的输出 作为整个函数的输出.
其中 表示网络中所有层的连接权重和偏置.
通用近似定理(Universal Approximation Theorem ) [Cy- benko, 1989; Hornik et al., 1989]: 令 是一个非常数、有界、单调递增的连续函数, 是一个 维的单位超立方体 是定义在 上的连续函数集合.对于任意给定的一个函数 ,存在一个整数 ,和一组实数 以及实数向量 ,以至于我 们可以定义函数 作为函数 的近似实现,即 其中 是一个很小的正数.
二. 反向传播
假设采用随机梯度下降进行神经网络参数学习,给定一个样本 ,将其输入到神经网络模型中,得到网络输出为 .假设损失函数为 ,要进行参数学习就需要计算损失函数关于每个参数的导数.
不失一般性,对第 层中的参数 和 计算偏导数.因为 的计算 涉及向量对矩阵的微分,十分繁銷,因此我们先计算 关于参数矩阵中每个元素的偏导数 .根据链式法则, 上式中的第二项都是目标函数关于第 层的神经元 的偏导数,称为误差项,可以一次计算得到,这样我们只需要计算三个偏导数, 分 别为 和 下面分别来计算这三个偏导数.
-
计算偏导数 因 ,偏导数 其中 为权重矩阵 的第 行, 表示第 个元素为 ,其余为 0 的行向量.
-
计算偏导数 因为 和 的函数关系为 ,因此偏导数 为 的单位矩阵.
-
计算偏导数 偏导数 表示第 层神经元对最终损失 的影响,也反映了最终损失对第 层神经元的敏感程度,因此一般称为第 层神经元的误差项,用 来表示.
误差项 也间接反映了不同神经元对网络能力的贡献程度,从而比较好地解决 了贡献度分配问题 ( Credit Assignment Problem, CAP ).
根据 ,有 根据 ,其中 为按位计算的函数,因此有 因此,根据链式法则,第 层的误差项为 其中 是向量的点积运算符,表示每个元素相乘.
从上式可以看出,第 层的误差项可以通过第 层的误差项计算得到,这就是误差的反向传播 ( BackPropagation, BP ).反向传播算法的含义是: 第 层的一个神经元的误差项 或敏感性 是所有与该神经元相连的第 层 的神经元的误差项的权重和.然后,再乘上该神经元激活函数的梯度.
在计算出上面三个偏导数之后,最初的式子可以写为 其中 相当于向量 和向量 的外积的第 个元素.上式可以进一步写为 因此, 关于第 层权重 的梯度为
同理, 关于第 层偏置 的梯度为 在计算出每一层的误差项之后,我们就可以得到每一层参数的梯度.因此,使用误差反向传播算法的前馈神经网络训练过程可以分为以下三步:
- 前馈计算每一层的净输入 和激活值 ,直到最后一层;
- 反向传播计算每一层的误差项 ;
- 计算每一层参数的偏导数,并更新参数.
三. 自动微分
自动微分(Automatic Differentiation,AD).
为简单起见,这里以一个神经网络中常见的复合函数的例子来说明自动微分的过程.令复合函数 为 其中 为输入标量, 和 分别为权重和偏置参数.
首先,我们将复合函数 分解为一系列的基本操作,并构成一个计算图 ( Computational Graph ).计算图是数学运算的图形化表示.计算图中的每个非叶子节点表示一个基本操作,每个叶子节点为一个输入变量或常量.下图给 出了当 时复合函数 的计算图,其中连边上的红色数字表示前向计算时复合函数中每个变量的实际取值.
从计算图上可以看出,复合函数 由 6 个基本函数 组成.如下图所示,每个基本函数的导数都十分简单,可以通过规则来实现.
整个复合函数 关于参数 和 的导数可以通过计算图上的节点 与参数 和 之间路径上所有的导数连乘来得到,即 以 为例,当 时,可以得到 如果函数和参数之间有多条路径,可以将这多条路径上的导数再进行相加,得到最终的梯度.
按照计算导数的顺序,自动微分可以分为两种模式:前向模式和反向模式.
前向模式 前向模式是按计算图中计算方向的相同方向来递归地计算梯度.以 为例,当 时,前向模式的累积计算顺序如下: 反向模式 反向模式是按计算图中计算方向的相反方向来递归地计算梯度.以 为例,当 时,反向模式的累积计算顺序如下: 前向模式和反向模式可以看作应用链式法则的两种梯度累积方式.从反向模式的计算顺序可以看出,反向模式和反向传播的计算梯度的方式相同.对于一般的函数形式 ,前向模式需要对每一个输入变量都进行一遍遍历,共需要 遍.而反向模式需要对每一个输出都进行一个遍历,共需要 遍.当 时,反向模式更高效.在前馈神经网络的参数学习中,风险函数为 ,输出为标量,因此采用反向模式为最有效的计算方式,只需要一遍计算.
静态计算图和动态计算图计算图按构建方式可以分为静态计算图(StaticCom-putational Graph)和动态计算图(Dynamic Computational Graph).在目前深度学习框架里,Theano和Ten-sorflow采用的是静态计算图,而DyNet、Chainer和PyTorch采用的是动态计算图.Tensorflow 2.0也支持了动态计算图.静态计算图是在编译时构建计算图,计算图构建好之后在程序运行时不能改变,而动态计算图是在程序运行时动态构建.两种构建方式各有优缺点.静态计算图在构建时可以进行优化,并行能力强,但灵活性比较差.动态计算图则不容易优化,当不同输入的网络结构不一致时,难以并行计算,但是灵活性比较高.
四. 卷积神经网络
相关介绍还可看知乎:深度学习中不同类型卷积的综合介绍和原文
卷积神经网络(Convolutional Neural Network,CNN或ConvNet)是一种具有局部连接、权重共享等特性的深层前馈神经网络.卷积神经网络最早主要是用来处理图像信息.在用全连接前馈网络来处理图像时,会存在以下两个问题:
- 参数太多:如果输入图像大小为 100×100×3(即图像高度为 100 ,宽度为 100 以及RGB 3 个颜色通道),在全连接前馈网络中,第一个隐藏层的每个神经元到输入层都有 100 × 100 × 3 = 30000 个互相独立的连接,每个连接都对应一个权重参数.随着隐藏层神经元数量的增多,参数的规模也会急剧增加.这会导致整个神经网络的训练效率非常低,也很容易出现过拟合.
- 局部不变性特征:自然图像中的物体都具有局部不变性特征,比如尺度缩放、平移、旋转等操作不影响其语义信息.而全连接前馈网络很难提取这些局部不变性特征,一般需要进行数据增强来提高性能.
卷积神经网络是受生物学上感受野机制的启发而提出的.感受野(Recep-tive Field)机制主要是指听觉、视觉等神经系统中一些神经元的特性,即神经元只接受其所支配的刺激区域内的信号.在视觉神经系统中,视觉皮层中的神经细胞的输出依赖于视网膜上的光感受器.视网膜上的光感受器受刺激兴奋时,将神经冲动信号传到视觉皮层,但不是所有视觉皮层中的神经元都会接受这些信号.一个神经元的感受野是指视网膜上的特定区域,只有这个区域内的刺激才能够激活该神经元.
目前的卷积神经网络一般是由卷积层、汇聚层和全连接层交叉堆叠而成的前馈神经网络.卷积神经网络有三个结构上的特性:局部连接、权重共享以及汇聚.这些特性使得卷积神经网络具有一定程度上的平移、缩放和旋转不变性.和前馈神经网络相比,卷积神经网络的参数更少.卷积神经网络主要使用在图像和视频分析的各种任务(比如图像分类、人脸识别、物体识别、图像分割等)上,其准确率一般也远远超出了其他的神经网络模型.近年来卷积神经网络也广泛地应用到自然语言处理、推荐系统等领域.
4.1 卷积
4.1.1 卷积的定义
卷积(Convolution),也叫褶积,是分析数学中一种重要的运算.在信号处理或图像处理中,经常使用一维或二维卷积.
4.1.1.1 一维卷积
一维卷积经常用在信号处理中,用于计算信号的延迟累积.假设一个信号发生器每个时刻 产生一个信号 ,其信息的衰减率为 ,即在 个时间步长后,信息为原来的 倍.假设 ,那么在时刻 收到的信号 为当前时刻产生的信息和以前时刻延迟信息的叠加. 我们把 称为滤波器 ( Filter ) 或卷积核 ( Convolution Kernel ).假设滤波器长度为 ,它和一个信号序列 的卷积为 为了简单起见,这里假设卷积的输出 的下标 从 开始.
信号序列 和滤波器 的卷积定义为 其中 表示卷积运算.一般情况下滤波器的长度 远小于信号序列 的长度.
我们可以设计不同的滤波器来提取信号序列的不同特征.比如,当令滤波器 时,卷积相当于信号序列的简单移动平均 窗口大小为 ;当令滤波器 时,可以近似实现对信号序列的二阶微分,即 下图给出了两个滤波器的一维卷积示例.可以看出,两个滤波器分别提取了输入序列的不同特征.滤波器 可以检测信号序列中的低频信息,而滤波器 可以检测信号序列中的高频信息.(高低频指信号变化的强烈程度)
4.1.1.2 二维卷积
卷积也经常用在图像处理中.因为图像为一个二维结构,所以需要将一维卷积进行扩展.给定一个图像 和一个滤波器 ,一般 ,其卷积为 为了简单起见,这里假设卷积的输出 的下标 从 开始.
输入信息 和滤波器 的二维卷积定义为 其中*表示二维卷积运算. 下图给出了二维卷积示例.
在图像处理中常用的均值滤波 ( Mean Filter ) 就是一种二维卷积,将当前位置的像素值设为滤波器窗口中所有像素的平均值,即 .
在图像处理中,卷积经常作为特征提取的有效方法.一幅图像在经过卷积操作后得到结果称为特征映射(Feature Map).下图给出在图像处理中几种常用的滤波器,以及其对应的特征映射.图中最上面的滤波器是常用的高斯滤波器,可以用来对图像进行平滑去噪;中间和最下面的滤波器可以用来提取边缘特征.
4.1.2 互相关
在机器学习和图像处理领域,卷积的主要功能是在一个图像 ( 或某种特征 ) 居滑动一个卷积核 ( 即滤波器 通过卷积操作得到一组新的特征.在计算卷积的过程中,需要进行卷积核翻转.在具体实现上,一般会以互相关操作来代替卷积,从而会减少一些不必要的操作或开销.互相关 ( Cross-Correlation ) 是一个 衡量两个序列相关性的函数,通常是用滑动窗口的点积计算来实现.给定一个图像 和卷积核 ,它们的互相关为 和公式 (4.1) 对比可知,互相关和卷积的区别仅仅在于卷积核是否进行翻转.因此互相关也可以称为不翻转卷积.
公式 (4.2) 可以表述为 其中 表示互相关运算, 表示旋转 180 度, 为输出 矩阵.
在神经网络中使用卷积是为了进行特征抽取,卷积核是否进行翻转和其特征抽取的能力无关.特别是当卷积核是可学习的参数时,卷积和互相关在能力上是等价的.因此,为了实现上 (或描述上 ) 的方便起见,我们用互相关来代替卷积.事实上,很多深度学习工具中卷积操作其实都是互相关操作.
4.1.3 卷积的变种
在卷积的标准定义基础上,还可以引入卷积核的滑动步长和零填充来增加卷积的多样性,可以更灵活地进行特征抽取.
- 步长(Stride)是指卷积核在滑动时的时间间隔.下图左给出了步长为2的卷积示例.(步长也可以小于1,即微步卷积)
- 零填充(Zero Padding)是在输入向量两端进行补零.下图右给出了输入的两端各补一个零后的卷积示例.
假设卷积层的输入神经元个数为 ,卷积大小为 ,步长为 ,在输入两端各填补 个 0 ( zero padding ) ,那么该卷积层的神经元数量为
一般常用的卷积有以下三类:
- 窄卷积 ( Narrow Convolution ) : 步长 ,两端不补零 ,卷积后输出长度为
- 宽卷积 ( Wide Convolution ) : 步长 ,两端补零 ,卷积后输出长度 . (3) 等宽卷积 ( Equal-Width Convolution ) 步长 ,两端补零 ,卷积后输出长度 .上图右就是一个等宽卷积示例.
4.1.4 卷积的数学性质
4.1.4.1 交换性
如果不限制两个卷积信号的长度,真正的翻转卷积是具有交换性的,即 .对于互相关的“卷积”,也同样具有一定的“交换性”.
我们先介绍宽卷积 ( Wide Convolution ) 的定义.给定一个二维图像 和一个二维卷积核 , 对图像 进行零填充,两端各补 和 个零,得到全填充 ( Full Padding 的图像 .图像 和卷积核 的宽卷积定义为 其中 表示宽卷积运算.当输入信息和卷积核有固定长度时,它们的宽卷积依然具有交换性,即 其中 表示旋转 180 度.
4.1.4.2 导数
假设 ,其中 ,函数 为一个标量函数,则 从上式可以看出, 关于 的偏导数为 和 的卷积 同理得到, 其中当 ,或 ,或 ,或 时, .即相当于对 进行了 的零填充.
从上式可以看出, 关于 的偏导数为 和 的宽卷积.上式中的卷积是真正的卷积而不是互相关,为了一致性,我们用互相关的“卷积”,即 其中 表示旋转 180 度.
4.2 卷积神经网络
卷积神经网络一般由卷积层、汇聚层和全连接层构成.
4.2.1 用卷积代替全连接
在全连接前馈神经网络中,如果第 层有 个神经元,第 层有 个神经元,连接边有 个,也就是权重矩阵有 个参数.当 和 都很大时,权重矩阵的参数非常多,训练的效率会非常低.
如果采用卷积来代替全连接,第 层的净输入 为第 层活性值 和卷积核 的卷积,即 其中卷积核 为可学习的权重向量, 为可学习的偏置.
根据卷积的定义,卷积层有两个很重要的性质 :
局部连接 在卷积层 假设是第 层 中的每一个神经元都只和下一层 第 层 ) 中某个局部窗口内的神经元相连,构成一个局部连接网络.如下图所示,卷积层和下一层之间的连接数大大减少,由原来的 个连接变为 个连接, 为卷积核大小.
权重共享 从上式可以看出,作为参数的卷积核 对于第 层的所有的神经元都是相同的.如下图中,所有的同颜色连接上的权重是相同的.权重共享可以理解为一个卷积核只捕捉输入数据中的一种特定的局部特征.因此,如果要提取多种特征就需要使用多个不同的卷积核.
由于局部连接和权重共享,卷积层的参数只有一个 维的权重 和 1 维的偏置 ,共 个参数.参数个数和神经元的数量无关.此外,第 层的神经 元个数不是任意选择的,而是满足 .
4.2.2 卷积层
卷积层的作用是提取一个局部区域的特征,不同的卷积核相当于不同的特征提取器.上一节中描述的卷积层的神经元和全连接网络一样都是一维结构.由于卷积网络主要应用在图像处理上,而图像为二维结构,因此为了更充分地利用图像的局部信息,通常将神经元组织为三维结构的神经层,其大小为高度 宽 度 深度 ,由 个 大小的特征映射构成.
特征映射 ( Feature Map ) 为一幅图像 ( 或其他特征映射 ) 在经过卷积提取到的特征,每个特征映射可以作为一类抽取的图像特征.为了提高卷积网络的表示能力,可以在每一层使用多个不同的特征映射,以更好地表示图像的特征.
在输入层,特征映射就是图像本身.如果是灰度图像,就是有一个特征映射,输入层的深度 ;如果是彩色图像,分别有 三个颜色通道的特征映射,输入层的深度 .
不失一般性,假设一个卷积层的结构如下:
- 输入特征映射组: 为三维张量 ( Tensor ), 其中每个切 片 ( Slice ) 矩阵 为一个输入特征映射, ;
- 输出特征映射组: 为三维张量,其中每个切片矩阵 为一个输出特征映射, ;
- 卷积核: 为四维张量,其中每个切片矩阵 为一个二维卷积核, .
下图给出卷积层的三维结构表示.
为了计算输出特征映射 ,用卷积核 分别对输入特征映射 进行卷积,然后将卷积结果相加,并加上一个标量偏置 得到卷积层的净输入 ,再经过非线性激活函数后得到输出特征映射 . 其中 为三维卷积核, 为非线性激活函数,一般用 函数.
整个计算过程如下图所示.如果希望卷积层输出 个特征映射,可以将上述计算过程重复 次,得到 个输出特征映射 .
在输入为 ,输出为 的卷积层中,每一个输出特征映射都需要 个卷积核以及一个偏置.假设每个卷积核的大小为 ,那么 共需要 个参数.
4.2.3 汇聚层
汇聚层 ( Pooling Layer ) 也叫子采样层 ( Subsampling Layer ) ,其作用是进行特征选择,降低特征数量,从而减少参数数量.
卷积层虽然可以显著减少网络中连接的数量,但特征映射组中的神经元个数并没有显著减少.如果后面接一个分类,分类器的输入维数依然很高,很容易出现过拟合.为了解决这个问题,可以在卷积层之后加上一个汇聚层,从而降低特征维数,避免过拟合.
假设汇聚层的输入特征映射组为 ,对于其中每一个特征映射 ,将其划分为很多区域 ,这些区域可以重叠,也可以不重叠,汇聚 ( Pooling ) 是指对每个区域进行下采样 ( Down Sampling ) 得到一个值,作为这个区域的概括.
常用的汇聚函数有两种:
- 最大汇聚 ( Maximum Pooling 或 Max Pooling ) :对于一个区域 ,选择这个区域内所有神经元的最大活性值作为这个区域的表示,即 其中 为区域 内每个神经元的活性值.
- 平均汇聚 ( Mean Pooling ) :一般是取区域内所有神经元活性值的平均值,即 对每一个输入特征映射 的 个区域进行子采样,得到汇聚层的输出特征映射 .
下图给出了采样最大汇聚进行子采样操作的示例.可以看出,汇聚层不但可以有效地减少神经元的数量,还可以使得网络对一些小的局部形态改变保持不变性,并拥有更大的感受野.
目前主流的卷积网络中,汇聚层仅包含下采样操作.但在早期的一些卷积网络 比如 LeNet-5 ) 中,有时也会在汇聚层使用非线性激活函数,比如 其中 为汇聚层的输出, 为非线性激活函数, 和 为可学习的标量权重和偏置.
典型的汇聚层是将每个特征映射划分为 大小的不重叠区域,然后使用最大汇聚的方式进行下采样.汇聚层也可以看作一个特殊的卷积层,卷积核大小 为 ,步长为 ,卷积核为 函数或 mean 函数.过大的采样区域会急剧减少神经元的数量,也会造成过多的信息损失.
4.2.4 卷积网络的整体结构
一个典型的卷积网络是由卷积层、汇聚层、全连接层交叉堆叠而成.目前常用的卷积网络整体结构如下图所示.一个卷积块为连续 个卷积层和 个汇聚层 通常设置为 为 0 或 1 ).一个卷积网络中可以堆叠 个连续的卷积块,然后在后面接着 个全连接层 的取值区间比较大,比如 或者更大; 一般为 ).
目前,卷积网络的整体结构趋向于使用更小的卷积核 比如 和 以及更深的结构 比如层数大于 50 ).此外,由于卷积的操作性越来越灵活 ( 比如不同的步长 ),汇聚层的作用也变得越来越小,因此目前比较流行的卷积网络中,汇聚层的比例正在逐渐降低,趋向于全卷积网络.
4.3 参数学习(卷积网络的反向传播)
在卷积网络中,参数为卷积核中权重以及偏置.和全连接前软网络类似,卷积网络也可以通过误差反向传播算法来进行参数学习.
在全连接前馈神经网络中,梯度主要通过每一层的误差项 进行反向传播,并进一步计算每层参数的梯度.
在卷积神经网络中,主要有两种不同功能的神经层:卷积层和汇聚层.而参数为卷积核以及偏置,因此只需要计算卷积层中参数的梯度.
不失一般性,对第 层为卷积层,第 层的输入特征映射为 ,通过卷积计算得到第 层的特征映射净输入 .第 层的第 个特征映射净输入 其中 和 为卷积核以及偏置.第 层中共有 个卷积核和 个偏 置,可以分别使用链式法则来计算其梯度.
根据上式,损失函数 关于第 层的卷积核 的偏 导数为 其中 为损失函数关于第 层的第 个特征映射净输入 的偏导数.
同理可得,损失函数关于第 层的第 个偏置 的偏导数为 在卷积网络中,每层参数的梯度依赖其所在层的误差项 .
4.3.1 卷积神经网络的反向传播算法
卷积层和汇聚层中误差项的计算有所不同,因此我们分别计算其误差项.
汇聚层 当第 层为汇聚层时,因为汇聚层是下采样操作, 层的每个神经元的误差项 对应于第 层的相应特征映射的一个区域. 层的第 个特征映射中的每个神经元都有一条边和 层的第 个特征映射中的一个神经元相连.根据链式法则,第 层的一个特征映射的误差项 ,只需要将 层对应特征映射的误差项 进行上采样操作 和第 层的大小一样 ,再和 层特征映射的激活值偏导数逐元素相乘,就得到了 .
第 层的第 个特征映射的误差项 的具体推导过程如下: 其中 为第 层使用的激活函数导数,up 为上采样函数 ( up sampling ),与汇聚层中使用的下采样操作刚好相反.如果下采样是最大汇聚,误差项 中每个值会直接传递到上一层对应区域中的最大值所对应的神经元,该区域中其他神经元的误差项都设为 .如果下采样是平均汇聚,误差项 中每个值会被平均分配到上一层对应区域中的所有神经元上.
卷积层 当 层为卷积层时,假设特征映射净输入 ,其中 第 个特征映射净输入 其中 和 为第 层的卷积核以及偏置.第 层中共有 个卷积核和 个偏置.
第 层的第 个特征映射的误差项 的具体推导过程如下: 其中 为宽卷积.
4.4 几种典型的卷积神经网络
4.4.1 LeNet-5
LeNet-5[LeCun et al., 1998 ] 虽然提出的时间比较早,但它是一个非常成功的神经网络模型.基于 LeNet-5 的手写数字识别系统在 20 世纪 90 年代被美国很多银行使用,用来识别支票上面的手写数字. LeNet-5的网络结构如下图所示.
LeNet-5共有 7 层,接受输入图像大小为 ,输出对应 10 个类别的得分. LeNet-5中的每一层结构如下:
- C1 层是卷积层,使用 6 个 的卷积核,得到 6 组大小为 的特征映射.因此, 层的神经元数量为 , 可训练参数数量为 , 连接数为 (包括偏置在内,下同 ).
- S2层为汇聚层,采样窗口为 ,使用平均汇聚,并使用一个非线性函数.神经元个数为 , 可训练参数数量为 , 连接数为 .
- C3 层为卷积层. LeNet-5 中用一个连接表来定义输入和输出特征映 射之间的依赖关系,如图5.11所示,共使用 60 个 的卷积核,得到 16 组大 小为 的特征映射. 神经元数量为 , 可训练参数数量为 , 连接数为
- S4 层是一个汇聚层, 采样窗口为 , 得到 16 个 大小的特征映射, 可训练参数数量为 , 连接数为
- C5 层是一个卷积层, 使用 个 的卷积核, 得到 120 组大小为 的特征映射. 层的神经元数量为 120, 可训练参数数量为 , 连接数为
- F6层是一个全连接层,有 84 个神经元, 可训练参数数量为 1 ) 164. 连接数和可训练参数个数相同,为
- 输出层:输出层由 10 个径向基函数 ( Radial Basis Function, ) 组成.这里不再详述.
连接表 从公式可以看出, 卷积层的每一个输出特征映射都依赖于所有输入特征映射,相当于卷积层的输入和输出特征映射之间是全连接的关系. 实际上, 这种全连接关系不是必须的. 我们可以让每一个输出特征映射都依赖于少数几个输入特征映射. 定义一个连接表 ( Link Table ) 来描述输入和输出特征映射之间的连接关系. 在 LeNet-5 中, 连接表的基本设定如下图所示. C3层的第 0 -5 个特征映射依赖于S2层的特征映射组的每 3 个连续子集,第 6-11个特征映射依赖于 层的特征映射组的每 4 个连续子集, 第 12-14 个特征映射依赖于 S2 层的特征映射的每 4 个不连续子集,第 15 个特征映射依赖于 层的所有特征映射.
如果第 个输出特征映射依赖于第 个输入特征映射, 则 , 否则为 为其中 为 大小的连接表. 假设连接表 的非零个数为 , 每个卷积核的大小为 ,那么共需要 参数.
4.4.2 AlexNet
AlexNet[Krizhevsky et al., 2012 ] 是第一个现代深度卷积网络模型, 其首次使用了很多现代深度卷积网络的技术方法, 比如使用 进行并行训练, 采有了 作为非线性激活函数, 使用 Dropout 防止过拟合, 使用数据增强来提高 模型准确率等. AlexNet 赢得了 2012 年 ImageNet 图像分类竞赛的冠军.
AlexNet 的结构如下图所示,包括 5 个卷积层、3个汇聚层和 3 个全连接层(其中最后一层是使用 Softmax 函数的输出层).因为网络规模超出了当时的单个 GPU的内存限制, AlexNet 将网络拆为两半,分别放在两个 上, GPU间只 在某些层 比如第 3 层 进行通信.
AlexNet 的输入为 的图像,输出为 1000 个类别的条件概率,具体结构如下:
- 第一个卷积层,使用两个大小为 的卷积核,步长 , 零填充 , 得到两个大小为 的特征映射组.
- 第一个汇聚层,使用大小为 的最大汇聚操作,步长 , 得到两个 的特征映射组.
- 第二个卷积层,使用两个大小为 的卷积核,步长 , 零填充 , 得到两个大小为 的特征映射组.
- 第二个汇聚层,使用大小为 的最大汇聚操作,步长 , 得到两个大小为 的特征映射组.
- 第三个卷积层为两个路径的融合,使用一个大小为 的卷积核, 步长 , 零填充 , 得到两个大小为 的特征映射组.
- 第四个卷积层, 使用两个大小为 的卷积核,步长 , 零填充 ,得到两个大小为 的特征映射组.
- 第五个卷积层, 使用两个大小为 的卷积核,步长 , 零填充 , 得到两个大小为 的特征映射组.
- 第三个汇聚层,使用大小为 的最大汇聚操作,步长 , 得到两个大小为 的特征映射组.
- 三个全连接层,神经元数量分别为 4096,4096 和 1000 .此外, AlexNet 还在前两个汇聚层之后进行了局部响应归一化 ( Local Re'nonse Normalization.LRN ) 以增强模型的泛化能力.
4.4.3 Inception网络
在卷积网络中,如何设置卷积层的卷积核大小是一个十分关键的问题. 在 Inception 网络中, 一个卷积层包含多个不同大小的卷积操作, 称为Inception 模块. Inception 网络是由有多个 Inception 模块和少量的汇聚层堆叠而成.
Inception 模块同时使用 等不同大小的卷积核, 并将得到的特征映射在深度上拼接 ( 堆叠 ) 起来作为输出特征映射.
下图给出了v1版本的 Inception 模块结构, 采用了 4 组平行的特征抽取方 式, 分别为 的卷积和 的最大汇聚. 同时, 为了提高计算效 率,减少参数数量, Inception 模块在进行 的卷积之前、 的最大汇聚之后,进行一次 的卷积来减少特征映射的深度. 如果输入特征映射之间存在冗余信息, 的卷积相当于先进行一次特征抽取.
Inception 网络有多个版本, 其中最早的 Inception v1 版本就是非常著名的 GoogLeNet [Szegedy et al., 2015]. GoogLeNet 赢得了 2014年 ImageNet 图像分 类竞赛的冠军.
GoogLeNet 由 9 个 Inception v1 模块和 5 个汇聚层以及其他一些卷积层和全连接层构成, 总共为 22 层网络,如下图所示.
为了解决梯度消失问题, GoogLeNet 在网络中间层引入两个辅助分类器来加强监督信息.
Inception 网络有多个改进版本, 其中比较有代表性的有Inception v3网络[Szegedy et al., 2016]. Inception v3 网络用多层的小卷积核来替换大的卷积核,以减少计算量和参数量,并保持感受野不变. 具体包括 ) 使用两层 的卷积来替换 中的 的卷积; 2 ) 使用连续的 和 来替换 的卷积.
此外, Inception v3 网络同时也引入了标签平滑以及批量归一化等优化方法进行训练.
4.4.4 残差网络
残差网络 ( Residual Network, ResNet ) 通过给非线性的卷积层增加直连边 ( Shortcut Connection ) ( 也称为残差连接 ( Residual Connection ) ) 的方式来提高信息的传播效率.
假设在一个深度网络中,我们期望一个非线性单元 (可以为一层或多层的卷积层 去逼近一个目标函数为 .如果将目标函数拆分成两部分 : 恒等函数 ( Identity Function ) 和残差函数 ( Residue Function) . 根据通用近似定理,一个由神经网络构成的非线性单元有足够的能力来近似逼近原始目标函数或残差函数,但实际中后者更容易学习 [He et al., 2016].因此,原来的优化问题可以转换为:让非线性单元 去近似残差函数 , 并用 去逼近 .
下图给出了一个典型的残差单元示例.残差单元由多个级联的 ( 等宽 ) 卷积层和一个跨层的直连边组成,再经过 ReLU 激活后得到输出.
残差网络就是将很多个残差单元串联起来构成的一个非常深的网络.和残差网络类似的还有 Highway Network[Srivastava et al., 2015].
4.5 其他卷积方式
在第4.1.3节中介绍了一些卷积的变种,可以通过步长和零填充来进行不同的卷积操作.本节介绍一些其他的卷积方式.
4.5.1 转置卷积
我们一般可以通过卷积操作来实现高维特征到低维特征的转换.比如在一维卷积中,一个 5 维的输入特征,经过一个大小为 3 的卷积核,其输出为 3 维特征.如果设置步长大于 1 ,可以进一步降低输出特征的维数.但在一些任务中,我们需要将低维特征映射到高维特征,并且依然希望通过卷积操作来实现.
假设有一个高维向量为 和一个低维向量为 .如果用仿射变换 ( Affine Transformation ) 来实现高维到低维的映射, 其中 为转换矩阵.我们可以很容易地通过转置 来实现低维到高维的反向映射,即 需要说明的是,上两式并不是逆运算,两个映射只是形式上 的转置关系.
在全连接网络中,忽略激活函数,前向计算和反向传播就是一种转置关系.比如前向计算时,第 层的净输入为 ,反向传播时,第 层的误差项为 .
卷积操作也可以写为仿射变换的形式.假设一个 5 维向量 ,经过大小为 3 的卷积核 进行卷积,得到 3 维向量 .卷积操作可以写为 其中 是一个稀疏矩阵,其非零元素来自于卷积核 中的元素.
如果要实现 3 维向量 到 5 维向量 的映射,可以通过仿射矩阵的转置来实现,即 其中 表示旋转 180 度.
可以看出,从仿射变换的角度来看两个卷积操作 和 也是形式上的转置关系.因此,我们将低维特征映射到高维特征的卷积操作称为转置卷积 ( Transposed Convolution ) [Dumoulin et al., 2016],也称为反卷积 ( Deconvolution ) [Zeiler et al., 2011].
在卷积网络中,卷积层的前向计算和反向传播也是一种转置关系.
对一个 维的向量 ,和大小为 的卷积核,如果希望通过卷积操作来映射到更高维的向量,只需要对向量 进行两端补零 ,然后进行卷积,可以得到 维的向量.
转置卷积同样适用于二维卷积.下图给出了一个步长 ,无零填充 的二维卷积和其对应的转置卷积.
微步卷积 我们可以通过增加卷积操作的步长 来实现对输入特征的下采样操作,大幅降低特征维数.同样,我们也可以通过减少转置卷积的步长 来实现上采样操作,大幅提高特征维数.步长 的转置卷积也称为微步卷积 ( Fractionally-Strided Convolution ) [Long et al., 2015].为了实现微步卷积,我们可以在输入特征之间插入 0 来间接地使得步长变小.
如果卷积操作的步长为 ,希望其对应的转置卷积的步长为 ,需要在输入特征之间插入 个 0 来使得其移动的速度变慢.
以一维转置卷积为例, 对一个 维的向量 ,和大小为 的卷积核,通过对向量 进行两端补零 ,并且在每两个向量元素之间插入 个 0 ,然后进行步长为 1 的卷积,可以得到 维的向量.
下图给出了一个步长 ,无零填充 的二维卷积和其对应的转置卷积.
4.5.2 空洞卷积
对于一个卷积层,如果希望增加输出单元的感受野,一般可以通过三种方式实现: 1 )增加卷积核的大小; 2 ) 增加层数,比如两层 的卷积可以近似一层 卷积的效果 ; 3 ) 在卷积之前进行汇聚操作.前两种方式会增加参数数量,而第三种方式会丢失一些信息.
空洞卷积(Atrous Convolution ) 是一种不增加参数数量,同时增加输出单元感受野的一种方法,也称为膨胀卷积 ( Dilated Convolution ) [Chen et al. 2018; Yu et al., 2015.
空洞卷积通过给卷积核插入 “空洞”来变相地增加其大小.如果在卷积核的每两个元素之间插入 个空洞,卷积核的有效大小为 其中 称为膨胀率 ( Dilation Rate ).当 时卷积核为普通的卷积核.
下图给出了空洞卷积的示例.
4.6 总结和深入阅读
卷积神经网络是受生物学上感受野机制启发而提出的.1959 年,[Hubel et al., 1959] 发现在猫的初级视觉皮层中存在两种细胞:简单细胞和复杂细胞.这两种细胞承担不同层次的视觉感知功能 [Hubel et al., 1962].简单细胞的感受野是狭长型的,每个简单细胞只对感受野中特定角度 ( orientation ) 的光带敏感,而复杂细胞对于感受野中以特定方向 ( direction ) 移动的某种角度 ( ori- entation ) 的光带敏感.受此启发,福岛邦彦 ( Kunihiko Fukushima ) 提出了一种带卷积和子采样操作的多层神经网络:新知机 ( Neocognitron ) [Fukushima, 1980].但当时还没有反向传播算法,新知机采用了无监督学习的方式来训练.[LeCun et al., 1989 ] 将反向传播算法引入了卷积神经网络,并在手写体数字识别上取得了很大的成功 [LeCun et al., 1998].
AlexNet[Krizhevsky et al., 2012 ] 是第一个现代深度卷积网络模型,可以说是深度学习技术在图像分类上真正突破的开端. AlexNet 不用预训练和逐层训练,首次使用了很多现代深度网络的技术,比如使用 GPU 进行并行训练,采用了 ReLU作为非线性激活函数,使用 Dropout 防止过拟合,使用数据增强来提高模型准确率等.这些技术极大地推动了端到端的深度学习模型的发展.
在 AlexNet 之后,出现了很多优秀的卷积网络,比如 VGG 网络 [Simonyan et al., 2014]、Inception v1,v2, v4 网络 [Szegedy et al., 2015, 2016, 2017] 、残差网 络 [He et al., 2016] 等.
目前,卷积神经网络已经成为计算机视觉领域的主流模型.通过引入跨层的直连边,可以训练上百层乃至上千层的卷积网络.随着网络层数的增加,卷积层越来越多地使用 和 大小的小卷积核,也出现了一些不规则的卷积操作,比如空洞卷积 [Chen et al., 2018; Yu et al., 2015] 可变形卷积 [Dai et al., 2017] 等.网络结构也逐渐趋向于全卷积网络 ( Fully Convolutional Network, FCN ) [Long et al., 2015],减少汇聚层和全连接层的作用.
各种卷积操作的可视化示例可以参考 [Dumoulin et al., 2016].
循环神经网络
循环神经网络(Recurrent Neural Network,RNN)是一类具有短期记忆能力的神经网络.在循环神经网络中,神经元不但可以接受其他神经元的信息,也可以接受自身的信息,形成具有环路的网络结构.和前馈神经网络相比,循环神经网络更加符合生物神经网络的结构.循环神经网络已经被广泛应用在语音识别、语言模型以及自然语言生成等任务上.循环神经网络的参数学习可以通过随时间反向传播算法来学习.随时间反向传播算法即按照时间的逆序将错误信息一步步地往前传递.当输入序列比较长时,会存在梯度爆炸和消失问题,也称为长程依赖问题.为了解决这个问题,人们对循环神经网络进行了很多的改进,其中最有效的改进方式引入门控机制(Gating Mechanism).
此外,循环神经网络可以很容易地扩展到两种更广义的记忆网络模型:递归神经网络和图网络.
一. 循环神经网络
给定一个输入序列 ,循环神经网络通过下面公式更新带反馈边的隐藏层的活性值 :
其中 , 为一个非线性函数,可以是一个前馈网络.
给出了循环神经网络的示例,其中“延时器”为一个虚拟单元,记录神经元的最近一次(或几次)活性值.
从数学上讲,上式可以看成一个动力系统.因此,隐藏层的活性值 在很多文献上也称为状态(State)或隐状态(Hidden State).由于循环神经网络具有短期记忆能力,相当于存储装置,因此其计算能力十分强大.理论上,循环神经网络可以近似任意的非线性动力系统.前馈神经网络可以模拟任何连续函数,而循环神经网络可以模拟任何程序.
二. 简单循环网络
简单循环网络(Simple Recurrent Network,SRN)只有一个隐藏层.在一个两层的前馈神经网络中,连接存在于相邻的层与层之间,隐藏层的节点之间是无连接的.而简单循环网络增加了从隐藏层到隐藏层的反馈连接.
令向量 表示在时刻 时网络的输入, 表示隐藏层状态(即隐藏层神经元活性值),则 不仅和当前时刻的输入 相关,也和上一个时刻的隐藏层状态 相关.简单循环网络在时刻 的更新公式为
其中 为隐藏层的净输入, 为状态-状态权重矩阵, 为状态-输入权重矩阵, 为偏置向量, 是非线性激活函数,通常为Logistic函数或Tanh函数.也经常直接写为
下图给出了按时间展开的循环神经网络:
循环神经网络的通用近似定理
一个完全连接的循环网络是任何非线性动力系统的近似器.
定理:循环神经网络的通用近似定理[Haykin,2009]:如果一个完全连接的循环神经网络有足够数量的sigmoid型隐藏神经元,那么它可以以任意的准确率去近似任何一个非线性动力系统
其中 为每个时刻的隐状态, 是外部输入, 是可测的状态转换函数, 是连续输出函数,并且对状态空间的紧致性没有限制.
三. 参数学习
循环神经网络的参数可以通过梯度下降方法来进行学习.
以随机梯度下降为例,给定一个训练样本 ,其中 为长度是 的输入序列, 是长度为 的标签序列.即在每个时刻 ,都有一个监督信息 ,我们定义时刻 的损失函数为 其中 为第 时刻的输出, 为可微分的损失函数,比如交叉熵.那么整个序列的损失函数为 整个序列的损失函数 关于参数 的梯度为 即每个时刻损失 对参数 的偏导数之和.
循环神经网络中存在一个递归调用的函数 , 因此其计算参数梯度的方式和前馈神经网络不太相同.在循环神经网络中主要有两种计算梯度的方式:随 时间反向传播 ( BPTT ) 算法和实时循环学习 ( RTRL ) 算法.
3.1 随时间反向传播
随时间反向传播 ( BackPropagation Through Time, BPTT ) 算法的主要思想是通过类似前馈神经网络的错误反向传播算法 [Werbos, 1990]来计算梯度.
BPTT 算法将循环神经网络看作一个展开的多层前馈网络,其中“每一层”对应循环网络中的“每个时刻”.这样,循环神经网络就可以按照前馈网络中的反向传播算法计算参数梯度.在“展开”的前馈网络中,所有层的参数是共 享的,因此参数的真实梯度是所有“展开层”的参数梯度之和.
计算偏导数 先来计算第 时刻损失对参数 的偏导数 .
因为参数 和隐藏层在每个时刻 的净输入 有关,因此第 时刻的损失函数 关于参数 的梯度为: 其中 表示**“直接”偏导数**,即公式 中保持 不变,对 进行求偏导数,得到 其中 为第 时刻隐状态的第 维; 是除了第 行值为 外,其余都为 0 的行向量.
定义误差项 为第 时刻的损失对第 时刻隐藏神经层的净输入 的导数,则当 时 由上面三式得到 将上式写成矩阵形式为 下图给出了误差项随时间进行反向传播算法的示例.
参数梯度 由几式,得到整个序列的损失函数 关于参数 的梯度 同理可得, 关于权重 和偏置 的梯度为 计算复杂度 在BPTT算法中,参数的梯度需要在一个完整的“前向”计算和“反向”计算后才能得到并进行参数更新.
3.2 实时循环学习
与反向传播的 BPTT 算法不同的是, 实时循环学习 ( Real-Time Recurrent Learning, RTRL ) 是通过前向传播的方式来计算梯度 [Williams et al., 1995].
假设循环神经网络中第 时刻的状态 为 其关于参数 的偏导数为 其中 是除了第 行值为 外,其余都为 0 的行向量.
RTRL 算法从第 1 个时刻开始,除了计算循环神经网络的隐状态之外,还利用上式依次前向计算偏导数 .
这样,假设第 个时刻存在一个监督信息,其损失函数为 ,就可以同时计 算损失函数对 的偏导数 这样在第 时刻,可以实时地计算损失 关于参数 的梯度,并更新参数.参数 和 的梯度也可以同样按上述方法实时计算.
两种算法比较 RTRL算法和 BPTT 算法都是基于梯度下降的算法,分别通过前向模式和反向模式应用链式法则来计算梯度.在循环神经网络中,一般网络输出维度远低于输入维度,因此 BPTT 算法的计算量会更小,但是 BPTT 算法需要保存所有时刻的中间梯度,空间复杂度较高.RTRL算法不需要梯度回传,因此非常 适合用于需要在线学习或无限序列的任务中.
四. 长程依赖问题
循环神经网络在学习过程中的主要问题是由于梯度消失或爆炸问题,很难建模长时间间隔 ( Long Range ) 的状态之间的依赖关系.
在 BPTT 算法中,将公式(6.36)展开得到 如果定义 ,则 若 ,当 时, .当间隔 比较大时,梯度也变得很大,会造成系统不稳定,称为梯度爆炸问题 ( Gradient Exploding Problem ).
相反,若 ,当 时, .当间隔 比较大时,梯度也变得非常小,会出现和深层前馈神经网络类似的梯度消失问题 ( Vanishing Gradient Problem ).
要注意的是,在循环神经网络中的梯度消失不是说 的梯度消失了,而是 的梯度消失了 (当间隔 比较大时 .也就是说,参数 的更新主要靠当前时刻 的几个相邻状态 来更新,长距离的状态对参数 没有影响.
由于循环神经网络经常使用非线性激活函数为 Logistic 函数或 Tanh 函数作为非线性激活函数,其导数值都小于 1 ,并且权重矩阵 也不会太大,因此如果时间间隔 过大, 会趋向于 0 ,因而经常会出现梯度消失问题.
虽然简单循环网络理论上可以建立长时间间隔的状态之间的依赖关系,但是由于梯度爆炸或消失问题,实际上只能学习到短期的依赖关系.这样,如果时刻 的输出 依赖于时刻 的输入 ,当间隔 比较大时 ,简单神经网络很难建模这种长距离的依赖关系,称为长程依赖问题 ( Long-Term Dependencies Problem ).
4.1 改进方法
为了避免梯度爆炸或消失问题,一种最直接的方式就是选取合适的参数,同时使用非饱和的激活函数,尽量使得 ,这种方式需要足够的人 工调参经验,限制了模型的广泛应用.比较有效的方式是通过改进模型或优化方法来缓解循环网络的梯度爆炸和梯度消失问题.
梯度爆炸 一般而言,循环网络的梯度爆炸问题比较容易解决,一般通过权重衰减或梯度截断来避免.
权重衰减是通过给参数增加 或 范数的正则化项来限制参数的取值范围,从而使得 .梯度截断是另一种有效的启发式方法,当梯度的模大于一定阈值时,就将它截断成为一个较小的数.
梯度消失 梯度消失是循环网络的主要问题.除了使用一些优化技巧外,更有效的方式就是改变模型,比如让 ,同时令 为单位矩阵,即 其中 是一个非线性函数, 为参数. 公式(6.49)中, 和 之间为线性依赖关系,且权重系数为 1 ,这样就不存在梯度爆炸或消失问题.但是,这种改变也丢失了神经元在反馈边上的非线性 激活的性质,因此也降低了模型的表示能力.
为了避免这个缺点, 我们可以采用一种更加有效的改进策略: 这样 和 之间为既有线性关系,也有非线性关系,并且可以缓解梯度消失问题.但这种改进依然存在两个问题:
- 梯度爆炸问题:令 为在第 时刻函数 的输入,在计算公式(6.34)中的误差项 时,梯度可能会过大,从而导致梯度爆炸问题.
- 记忆容量 ( Memory Capacity ) 问题:随着 不断累积存储新的输入信息,会发生饱和现象.假设 为 Logistic 函数,则随着时间 的增长, 会变得越来越大,从而导致 变得饱和.也就是说,隐状态 可以存储的信息是有限的,随着记忆单元存储的内容越来越多,其丢失的信息也越来越多.
为了解决这两个问题,可以通过引入门控机制来进一步改进模型.
五. 基于门控的循环神经网络
为了改善循环神经网络的长程依赖问题,一种非常好的解决方案是在公式(6.50)的基础上引入门控机制来控制信息的累积速度,包括有选择地加入新的信息,并有选择地遗忘之前累积的信息. 这一类网络可以称为基于门控的循环神经网络 ( Gated RNN ) . 本节中,主要介绍两种基于门控的循环神经网络:长短期记忆网络和门控循环单元网络.
5.1 长短期记忆网络
长短期记忆网络 ( Long Short-Term Memory Network, LSTM ) [Gers et al. 2000; Hochreiter et al., 1997] 是循环神经网络的一个变体,可以有效地解决简单 循环神经网络的梯度爆炸或消失问题.
在公式 的基础上,LSTM 网络主要改进在以下两个方面:
新的内部状态 LSTM 网络引入一个新的内部状态 ( internal state ) 专门进行线性的循环信息传递,同时 非线性地 输出信息给隐藏层的外部状态 .内部状态 通过下面公式计算; 其中 和 为三个门 gate 来控制信息传递的路径; 为向量元素乘积 为上一时刻的记忆单元 是通过非线性函数得到的候选状态: 在每个时刻 网络的内部状态 记录了到当前时刻为止的历史信息.
门控机制 在数字电路中,门 gate 为一个二值变量 代表关闭状态,不许任何信息通过 代表开放状态,允许所有信息通过
LSTM 网络引入门控机制 ( Gating Mechanism ) 来控制信息传递的路径. 公式 (4.1)中三个 "门"分别为输入门 遗忘门 和输出门 .这二个门的作用为
- 遗忘门 控制上一个时刻的内部状态 需要遗忘多少信息.
- 输入门 控制当前时刻的候选状态 有多少信息需要保存.
- 输出门 控制当前时刻的内部状态 有多少信息需要输出给外部状态
当 时,记忆单元将历史信息清空,并将候选状态向量 写入.但此时记忆单元 依然和上一时刻的历史信息相关.当 时,记忆单元将复制上一时刻的内容,不写入新的信息.
LSTM 网络中的“门”是一种“软”门,取值在 之间,表示以一定的比例允许信息通过.三个门的计算方式为: 其中 为 Logistic 函数,其输出区间为 为当前时刻的输入, 为上一时刻的外部状态.
下图给出了 LSTM 网络的循环单元结构,其计算过程为 ) 首先利用上一时刻的外部状态 和当前时刻的输入 ,计算出三个门,以及候选状态 ) 结合遗忘门 和输入门 来更新记忆单元 ) 结合输出门 ,将内部状态的信息传递给外部状态 .
通过 循环单元,整个网络可以建立较长距离的时序依赖关系.公式可以简洁地描述为 其中 为当前时刻的输入, 和 为网络参数.
记忆 循环神经网络中的隐状态 存储了历史信息,可以看作一种记忆 ( Mem- ory ).在简单循环网络中,隐状态每个时刻都会被重写,因此可以看作一种短期记忆 ( Short-Term Memory ).在神经网络中,长期记忆 ( Long-Term Memory ) 可以看作网络参数,隐含了从训练数据中学到的经验,其更新周期要远远慢于短期记忆.而在 LSTM 网络中,记忆单元 可以在某个时刻捕捉到某个关键信息,并有能力将此关键信息保存一定的时间间隔.记忆单元 中保存信息的生命周期要长于短期记忆 ,但又远远短于长期记忆,因此称为长短期记忆 ( Long Short-Term Memory ).
一般在深度网络参数学习时,参数初始化的值一般都比较小.但是在训练 LSTM 网络时,过小的值会使得遗忘门的值比较小.这意味着前一时刻的信息大部分都丢失了,这样网络很难捕捉到长距离的依赖信息.并且相邻时间间隔的梯度会非常小,这会导致梯度弥散问题.因此遗忘的参数初始值一般都设得比较大,其偏置向量 设为 1 或2.
5.2 LSTM网络的各种变体
目前主流的 LSTM 网络用三个门来动态地控制内部状态应该遗忘多少历史信息,输入多少新信息,以及输出多少信息.我们可以对门控机制进行改进并获得 LSTM 网络的不同变体.
无遗忘门的 LSTM 网络 [Hochreiter et al., 1997] 最早提出的 LSTM 网络是没有遗忘门的,其内部状态的更新为 如之前的分析,记忆单元 会不断增大.当输入序列的长度非常大时,记忆单元的容量会饱和,从而大大降低 LSTM 模型的性能. peephole 连接 另外一种变体是三个门不但依赖于输入 和上一时刻的隐状态 ,也依赖于上一个时刻的记忆单元 ,即 其中 和 为对角矩阵.
耦合输入门和遗忘门 LSTM 网络中的输入门和遗忘门有些互补关系,因此同时用两个门比较冗余.为了减少 LSTM 网络的计算复杂度,将这两门合并为一个 门.令 , 内部状态的更新方式为
5.3 门控循环单元网络
门控循环单元 ( Gated Recurrent Unit, GRU ) 网络 [Cho et al., 2014; Chung et al., 2014] 是一种比 LSTM 网络更加简单的循环神经网络.
GRU 网络引入门控机制来控制信息更新的方式.和 LSTM 不同,GRU 不引入额外的记忆单元, GRU 网络也是在公式 的基础上引入一个更新门 ( Up- date Gate ) 来控制当前状态需要从历史状态中保留多少信息 ( 不经过非线性变换 ) ,以及需要从候选状态中接受多少新信息,即 其中 为更新门 在 LSTM 网络中,输入门和遗忘门是互补关系,具有一定的咒余性.GRU 网络直接使用一个门来控制输入和遗忘之间的平衡.当 时,当前状态 和前一时刻的状态 之间为非线性函数关系;当 时, 和 之间为线性函数关系.
在 GRU 网络中,函数 的定义为 其中 表示当前时刻的候选状态, 为重置门 ( Reset Gate ) 用来控制候选状态 的计算是否依赖上一时刻的状态 . 当 时,候选状态 只和当前输入 相关,和历史 状态无关.当 时,候选状态 和当前输入 以及历史状态 相关,和简单循环网络一致.
综上,GRU网络的状态更新方式为 可以看出,当 时,GRU 网络退化为简单循环网络;若 时,当前状态 只和当前输入 相关,和历史状态 无关. 当 时,当前状态 等于上一时刻状态 ,和当前输入 无关.
下图给出了GRU 网络的循环单元结构.
六. 深层循环神经网络
如果将深度定义为网络中信息传递路径长度的话,循环神经网络可以看作既“深”又“浅”的网络.一方面来说,如果我们把循环网络按时间展开,长时间间隔的状态之间的路径很长,循环网络可以看作一个非常深的网络.从另一方面来说,如果同一时刻网络输入到输出之间的路径 ,这个网络是非常浅的.
因此,我们可以增加循环神经网络的深度从而增强循环神经网络的能力.增加循环神经网络的深度主要是增加同一时刻网络输入到输出之间的路径 ,比如增加隐状态到输出 ,以及输入到隐状态 之间的路径的 深度.
6.1 堆叠循环神经网络
一种常见的增加循环神经网络深度的做法是将多个循环网络堆叠起来,称为堆叠循环神经网络 ( Stacked Recurrent Neural Network, SRNN ) .一个堆叠的简单循环网络 ( Stacked SRN ) 也称为循环多层感知器 ( Recurrent Multi-Layer Perceptron, RMLP ) [Parlos et al., 1991].
下图给出了按时间展开的堆肯循环神经网络.第 层网络的输入是第 层网络的输出.我们定义 为在时刻 时第 层的隐状态 其中 和 为权重矩阵和偏置向量,.
6.2 双向循环神经网络
在有些任务中,一个时刻的输出不但和过去时刻的信息有关,也和后续时刻的信息有关.比如给定一个句子,其中一个词的词性由它的上下文决定,即包含左右两边的信息.因此,在这些任务中,我们可以增加一个按照时间的逆序来传递信息的网络层,来增强网络的能力.
双向循环神经网络 ( Bidirectional Recurrent Neural Network, Bi-RNN ) 由两层循环神经网络组成,它们的输入相同,只是信息传递的方向不同.
假设第 1 层按时间顺序,第 2 层按时间逆序,在时刻 时的隐状态定义为 和 , 则 其中 为向量拼接操作.
下图给出了按时间展开的双向循环神经网络.
七. 扩展到图结构
如果将循环神经网络按时间展开,每个时刻的隐状态 看作一个节点,那么这些节点构成一个链式结构,每个节点 都收到其父节点的消息(Message),更新自己的状态,并传递给其子节点.而链式结构是一种特殊的图结构,我们可以比较容易地将这种消息传递 ( Message Passing ) 的思想扩展到任意的图结 构上.
7.1 递归神经网络
递归神经网络 ( Recursive Neural Network, ) 是循环神经网络在有向无循环图上的扩展 [Pollack, 1990].递归神经网络的一般结构为树状的层次结构,如下图左所示.
以上图左中的结构为例,有三个隐藏层 和 , 其中 由两个输入层 和 计算得到, 由另外两个输入层 和 计算得到, 由两个隐藏层 和 计算得到.
对于一个节点 ,它可以接受来自父节点集合 中所有节点的消息,并更新自己的状态. 其中 表示集合 中所有节点状态的拼接, 是一个和节点位置无关的非线性函数,可以为一个单层的前馈神经网络.比如上图左所示的递归神经网络具体可以写为 其中 表示非线性激活函数, 和 是可学习的参数.同样,输出层 可以为一个分类器,比如 其中 为分类器, 和 为分类器的参数.当递归神经网络的结构退化为线性序列结构 (上图右) 时,递归神经网络就等价于简单循环网络.
递归神经网络主要用来建模自然语言句子的语义[Socher et al., 2011,2013].给定一个句子的语法结构 ( 一般为树状结构 ),可以使用递归神经网络来按照句法的组合关系来合成一个句子的语义.句子中每个短语成分又可以分成一些子 成分,即每个短语的语义都可以由它的子成分语义组合而来,并进而合成整句的语义.
同样,我们也可以用门控机制来改进递归神经网络中的长距离依赖问题,比如树结构的长短期记忆模型 ( Tree-Structured LSTM ) [Tai et al., 2015; Zhu et al., 2015 ] 就是将 LSTM 模型的思想应用到树结构的网络中,来实现更灵活的组合函数.
7.2 图神经网络
在实际应用中,很多数据是图结构的,比如知识图谱、社交网络、分子网络等.而前馈网络和反馈网络很难处理图结构的数据.
图神经网络 ( Graph Neural Network, GNN ) 是将消息传递的思想扩展到图结构数据上的神经网络.
对于一个任意的图结构 ,其中 表示节点集合, 表示边集合.每条边表示两个节点之间的依赖关系.节点之间的连接可以是有向的,也可以是无向的.图中每个节点 都用一组神经元来表示其状态 , 初始状态可以为节点 的输入特征 .每个节点可以收到来自相邻节点的消息,并更新自己的状态. 其中 表示节点 的邻居, 表示在第 时刻节点 收到的信息, 为 边 上的特征.
上式是一种同步的更新方式,所有的结构同时接受信息并更新自己的状态.而对于有向图来说,使用异步的更新方式会更有效率,比如循环神经网络或递归神经网络,在整个图更新 次后,可以通过一个读出函数 ( Readout Function ) 来得到整个网络的表示:
八. 总结和深入阅读
循环神经网络可以建模时间序列数据之间的相关性.和延时神经网络[Lang et al., 1990; Waibel et al., 1989] 以及有外部输入的非线性自回归模型[Leontaritis et al., 1985 ]相比,循环神经网络可以更方便地建模长时间间隔的相关性.
常用的循环神经网络的参数学习算法是 BPTT算法 [Werbos, 1990],其计算时间和空间要求会随时间线性增长.为了提高效率,当输入序列的长度比较大时,可以使用带截断 ( truncated ) 的 BPTT算法[Williams et al., 1990],只计算固定时间间隔内的梯度回传.
一个完全连接的循环神经网络有着强大的计算和表示能力,可以近似任何非线性动力系统以及图灵机,解决所有的可计算问题.然而由于梯度爆炸和梯度消失问题,简单循环网络存在长期依赖问题[Bengio et al., 1994; Hochreiter et al., 2001].为了解决这个问题,人们对循环神经网络进行了很多的改进,其中最有效的改进方式为引入门控机制,比如 LSTM 网络 [Gers et al., 2000; Hochreiter et al., 1997]和GRU网络[Chung et al., 2014].当然还有一些其他方法,比如时钟循环神经网络 ( Clockwork RNN ) [Koutnik et al., 2014]、乘法RNN[Sutskever et al., 2011; Wu et al., 2016] 以及引入注意力机制等.
LSTM 网络是目前为止最成功的循环神经网络模型,成功应用在很多领域,比如语音识别、机器翻译 [Sutskever et al., 2014] 语音模型以及文本生成. LSTM 网络通过引入线性连接来缓解长距离依赖问题.虽然 LSTM 网络取得了很大的 成功,其结构的合理性一直受到广泛关注.人们不断尝试对其进行改进来寻找最优结构,比如减少门的数量、提高并行能力等.关于 LSTM 网络的分析可以参考文献 [Greff et al., 2017; Jozefowicz et al., 2015; Karpathy et al., 2015].
LSTM 网络的线性连接以及门控机制是一种十分有效的避免梯度消失问题的方法.这种机制也可以用在深层的前馈网络中,比如残差网络 [He et al., 2016] 和高速网络[Srivastava et al., 2015] 都通过引入线性连接来训练非常深的卷积网络.对于循环神经网格,这种机制也可以用在非时间维度上,比如 Gird LSTM 网络 [Kalchbrenner et al., 2015] 、Depth Gated RNN[Chung et al., 2015 等.
此外,循环神经网络可以很容易地扩展到更广义的图结构数据上,称为图网络[Scarselli et al., 2009].递归神经网络是一种在有向无环图上的简单的图网络.图网络是目前新兴的研究方向,还没有比较成熟的网络模型.在不同的网络结构以及任务上,都有很多不同的具体实现方式.其中比较有名的图网络模型包括图卷积网络 ( Graph Convolutional Network, GCN ) [Kipf et al., 2016]、图注意力网络 ( Graph Attention Network, GAT ) [Veličković et al., 2017] 消息传递神经网络 ( Message Passing Neural Network, MPNN ) [Gilmer et al., 2017] 等.关于图网络的综述可以参考文献 [Battaglia et al., 2018].
生成对抗网络
产生于2014年,论文地址 Ian J. Goodfellow
生动的白话例子:莫烦教程
又李宏毅课上举的例子:类似生物的拟态,枯叶蝶进化中不断地去模仿叶子的形态以逃避天敌的捕食.
简而言之,有两个网络,生成网络(generative network,即枯叶蝶自身形态的进化)和对抗网络(adversarial network,即天敌对它的分辨),生成网络负责生成样本(可能依据一个分布得到的随机数来生成),而对抗网络负责判断这个样本是真实样本还是生成样本,两个网络共同训练.
一. 概率生成模型
概率生成模型(Probabilistic Generative Model),简称生成模型,指一系列用于随机生成可观测数据的模型.假设在一个连续或离散的高维空间 中,存在一个随机向量 服从一个未知的数据分布 .生成模型是根据一些可观测的样本 来学习一个参数化的模型 来近似未知分布 ,并可以用这个模型来生成一些样本,使得“生成”的样本和“真实”的样本尽可能地相似.生成模型通常包含两个基本功能:概率密度估计和生成样本(即采样).
1.1 密度估计
给定一组数据 ,假设它们都是独立地从相同的概率密度函数为 的未知分布中产生的.密度估计(Density Estimation)是根据数据集 来估计其概率密度函数 .
直接建模 比较困难.因此,我们通常通过引入隐变量 来简化模型,这样密度估计问题可以转换为估计变量 的两个局部条件概率 和 .一般为了简化模型,假设隐变量 的先验分布为标准高斯分布 .隐变量 的每一维之间都是独立的.在这个假设下,先验分布 中没有参数.因此,密度估计的重点是估计条件分布 .
1.2 生成样本
生成样本就是给定一个概率密度函数为 的分布,生成一些服从这个分布的样本,也称为采样.
在得到两个变量的局部条件概率 和 之后,我们就可以生成数据 ,具体过程可以分为两步进行:
- 根据隐变量的先验分布 进行采样,得到样本 .
- 根据条件分布 进行采样,得到样本 .
为了便于采样,通常 不能太过复杂.因此,另一种生成样本的思想是从一个简单分布 ( 比如标准正态分布 ) 中采集一个样本 , 并利用一个深度神经网络 使得 服从 这样,我们就可以避免密度估计问题,并有效降低生成样本的难度,这正是生成对抗网络的思想.
1.3 生成对抗网络
一种无监督学习.注意到生成网络与真实样本是未接触的,判别网络根据真实样本来更新参数,而生成网络根据判别网络来更新参数.
1.3.1 显式密度模型和隐式密度模型
一些深度生成模型,比如变分自编码器、深度信念网络等,都是显示地构建出样本的密度函数 ,并通过最大似然估计来求解参数,称为显式密度模型(Explicit Density Model).
如果只是希望有一个模型能生成符合数据分布 的样本,那么可以不显式地估计出数据分布的密度函数.假设在低维空间 中有一个简单容易采样的 分布 通常为标准多元正态分布 .我们用神经网络构建一个映射函数 ,称为生成网络.利用神经网络强大的拟合能力,使得 服从分布 .这种模型就称为隐式密度模型 ( Implicit Density Model ).所谓隐式模型就是指并不显式地建模 ,而是建模生成过程.
1.3.2 网络分解
生成对抗网络(Generative Adversarial Networks,GAN)[Goodfellowet al.,2014]是通过对抗训练的方式来使得生成网络产生的样本服从真实数据分布.在生成对抗网络中,有两个网络进行对抗训练.一个是判别网络,目标是尽量准确地判断一个样本是来自于真实数据还是由生成网络产生;另一个是生成网络,目标是尽量生成判别网络无法区分来源的样本.
1.3.2.1 判别网络
判别网络 ( Discriminator Network ) 的目标是区分出一个样本 是来自于真实分布 还是来自于生成模型 ,因此判别网络实际上是一个二分类的分类器.用标签 来表示样本来自真实分布, 表示样本来自生成模型,判别网络 的输出为 属于真实数据分布的概率,即
则样本来自生成模型的概率为
.给定一个样本 表示其来自于 还是 .判别网络的
目标函数为最小化交叉嫡,即
假设分布 是由分布 和分布 等比例混合而成,即
,则上式等价于
其中 和 分别是生成网络和判别网络的参数.
>回忆交叉熵定义: ,其中 为真实值, 为以 为参数, 为输入的模型输出的估计值.
1.3.2.2 生成网络
生成网络(Generator Network)的目标刚好和判别网络相反,即让判别网络将自己生成的样本判别为真实样本.
上面的这两个目标函数是等价的.但是在实际训练时,一般使用前者,因为其梯度性质更好.我们知道,函数 在 接近 时的梯度要比接近 时的梯度小很多,接近“饱和”区间.这样,当判别网络 以很高的概率认为生成网络 产生的样本是“假”样本时, 即 , 目标函数关于 的 梯度反而很小,从而不利于优化.
1.3.2.3 训练
和单目标的优化任务相比,生成对抗网络的两个网络的优化目标刚好相反.因此生成对抗网络的训练比较难,往往不太稳定.一般情况下,需要平衡两个网络的能力.对于判别网络来说,一开始的判别能力不能太强,否则难以提升生成网络的能力.但是,判别网络的判别能力也不能太弱,否则针对它训练的生成网络也不会太好.在训练时需要使用一些技巧,使得在每次迭代中,判别网络比生成网络的能力强一些,但又不能强太多.
生成对抗网络的训练流程如下图所示.每次迭代时,判别网络更新 𝐾 次而生成网络更新一次,即首先要保证判别网络足够强才能开始训练生成网络.在实践中 𝐾 是一个超参数,其取值一般取决于具体任务.
1.3.2.4 难点
生成对抗网络训练的难点在于,不像一般的loss function,我们只要看loss收不收敛就知道训练效果.GAN中最后判断网络无法辨别样本来自真实网络还是生成网络,可能是由于生成网络训练得很好,但也可能是由于判别网络训练得太差,反之亦然.
DRL
强化学习基础
一. 有模型数值迭代
1.1 度量空间与压缩映射
1.1.1 度量空间及其完备性
度量 ( metric,又称距离 ),是定义在集合上的二元函数.对于集合 ,其上的度量 ,需要满足
- 非负性:对任意的 , 有
- 同一性:对任意的 , 如果 , 则
- 对称性:对任意的 , 有
- 三角不等式: 对任意的 , 有 .
有序对 又称为度量空间 (metric space).我们来看一个度量空间的例子.考虑有限 Markov 决策过程状态函数 ,其所有可能的取值组成集合 ,定义 如下: 可以证明, 是 上的一个度量.(证明: 非负性、同一性、对称性是显然的.由于 有 可得三角不等式.)所以, 是一个度量空间.对于一个度量空间,如果 Cauchy 序列都收敛在该空间内,则称这个度量空间是完备的(complete).对于度量空间 也是完备的.(证明: 考虑其中任意 Cauchy 列 ,即对任意的正实数 ,存在正整数 使得任意的 ,均有 对于 ,所以 是 Cauchy 列.由实数集的完备性,可以知道 收敛于某个实数,记这个实数为 .所以,对于 ,存在正整数 ,对于任意 ,有 取 ,有 ,所以 收敛于 ,而 ,完备性得证).
1.1.2 压缩映射与Bellman算子
本节介绍压缩映射的定义,并证明 Bellman 期望算子和 Bellman 最优算子是度量空间 上的压缩映射.
对于一个度量空间 和其上的一个映射 ,如果存在某个实数 ,使得对于任意的 ,都有 则称映射 是压缩映射 ( contraction mapping, 或 Lipschitzian mapping).其中的实数 被称为 Lipschitz 常数.
※ Bellman期望方程
用状态价值函数表示状态价值函数: 用动作价值函数表示动作价值函数:
※ Bellman最优方程
用最优状态价值函数表示最优状态价值函数: 用最优动作价值函数表示最优动作价值函数:
这两个方程都有用状态价值表示状态价值的形式.根据这个形式,我们可以为度量空间 定义 Bellman 期望算子 和 Bellman 最优算子.
给定策略 的 Bellman 期望算子 Bellman 最优算子 : 下面我们就来证明,这两个算子都是压缩映射.
首先来看 期望算子 .由 的定义可知,对任意的 ,有 所以 考虑到 是任取的,所以有 当 时, 就是压缩映射.接下来看 Bellman 最优算子 .要证明 是压缩映射,需要用到下列不等式: 其中 和 是任意的以 为自变量的函数。(证明: 设 , 则 同理可证 ,于是不等式得证. 利用这个不等 式,对任意的 ,有 进而易知 ,所以 是压缩映射.
1.1.3 Banach不动点定理
对于度量空间 上的映射 ,如果 使得 ,则称 是映射 的不动点 (fix point).
例如,策略 的状态价值函数 满足 Bellman 期望方程,是 Bellman 期望算子 的不动点.最优状态价值 满足 Bellman 最优方程,是 Bellman 最优算子 的不动点.
完备度量空间上的压缩映射有非常重要的结论: Banach 不动点定理. Banach 不动点定理(Banach fixed-point theorem, 又称压缩映射定理, compressed mapping theorem) 的内容是: 是非空的完备度量空间, 是一个压缩映射,则映射 在 内有且仅有一个不动点 .更进一步,这个不动点可以通过下列方法求出:从 内的任意一个元素 开始,定义迭代序列 ,这个序列收敛,且极限为 .
证明:考虑任取的 及其确定的列 ,我们可以证明它是 Cauchy 序列.对于任意的 且 ,用距离的三角不等式和非负性可知, 再反复利用压缩映射可知,对于任意的正整数 有 ,代人得: 由于 ,所以上述不等式右端可以任意小,得证.
Banach 不动点定理给出了求完备度量空间中压缩映射不动点的方法:从任意的起点开始,不断迭代使用压缩映射,最终就能收敛到不动点.并且在证明的过程中,还给出了收敛速度,即迭代正比于 的速度收敛 其中 是迭代次数).在 节我们已经证明 是完备的度量空间,而 节又证明了 Bellman 期望算子和 最优算子是压缩映射,那么就可以用迭代的方法求 Bellman 期望算子和 Bellman 最优算子的不动点.于 期望算子的不动点就是策略价值,Bellman 最优算子的不动点就是最优价值,所以这就意味着我们可以用迭代的方法求得策略的价值或最优价值.在后面的小节中,就来具体看看求解的算法.
1.2 有模型策略迭代
本节介绍在给定动力系统 的情况下的策略评估、策略改进和策略迭代.策略评估、策略改进和策略迭代分别指以下操作.
- 策略评估(policy evaluation): 对于给定的策略 ,估计策略的价值, 包括动作价值和状态价值
- 策略改进(policy improvement): 对于给定的策略 ,在已知其价值函数的情况 找到一个更优的策略
- 策略迭代(policy iteration): 综合利用策略评估和策略改进,找到最优策略
1.2.1 策略评估
本节介绍如何用迭代方法评估给定策略的价值函数.如果能求得状态价值函数,那么就能很容易地求出动作价值函数.由于状态价值函数只有 个自变量,而动作价值函数有 个自变量,所以存储状态价值函数比较节约空间.
用迭代的方法评估给定策略的价值函数的算法如算法 1-1 所示.算法 1-1 一开始初始化状态价值函数 ,并在后续的迭代中用 期望方程的表达式更新一轮所有状态的状态价值函数.这样对所有状态价值函数的一次更新又称为一次扫描(sweep).在第 次扫描时,用 的值来更新 的值,最终得到一系列的 .
算法 1-1:有模型策略评估迭代算法
输入: 动力系统 , 策略 输出:状态价值函数 的估计值 参数: 控制迭代次数的参数(如误差容忍度 或最大迭代次数
- (初始化) 对于 ,将 初始化为任意值 (比如 0 ).如果有终止状态,将终止状态初始化为 0,即
- (迭代) 对于 ,迭代执行以下步骤 2.1 对于 , 逐一更新 ,其中. 2.2 如果满足迭代终止条件 (如对 均有 ,或达到最大迭代次数 ,则跳出循环
值得一提的是,算法 1-1 没必要为每次捉描都重新分配一套空间来存储.一种优化的方法是,设置奇数次迭代的存储空间和偶数次迭代的存储空间,一开始初始化偶数次存储空间,当 是奇数时,用偶数次存储空间来更新奇数次存储空间; 当 是偶数时, 用奇数次存储空间来更新偶数次存储空间.这样,一共只需要两套存储空间就可以完成算法.
1.2.2 策略改进
对于给定的策略 ,如果得到该策略的价值函数,则可以用策略改进定理得到一个改进的策略.
策略改进定理的内容如下:对于策略 和 ,如果 则 ,即 在此基础上,如果存在状态使得第一式的不等号是严格小于号,那么就存在状态使得第二式中的不等号也是严格小于号.
证明: 考虑到第一个不等式等价于 其中的期望是针对用策略 生成的轨迹中,选取 的那些轨迹而言的.进而有 考虑到 所以 进而有 严格不等号的证明类似.
对于一个确定性策略 ,如果存在着 ,使得 ,那么我们可以构造一个新的确定策略 ,它在状态 做动作 ,而在除状态 以外的状态的动作都和策略一样.可以验证,策略 和 满足策略改进定理的条件.这样,我们就得到了一个比策略 更好的策略 .这样的策略更新算法可以用算法 1-2 来表示.
算法 1-2:有模型策略改进算法
输入: 动力系统 ,策略 及其状态价值函数 输出:改进的策略 ,或策略 已经达到最优的标志
- 对于每个状态 ,执行以下步骤: 1.1 为每个动作 ,求得动作价值函数 1.2 找到使得 最大的动作 ,即
- 如果新策略 和旧策略 相同,则说明旧策略巳是最优; 否则,输出改进的新策略
值得一提的是,在算法 1-2 中,旧策略 和新策略 只在某些状态上有不同的动作值, 新策略 可以很方便地在旧策略 的基础上修改得到.所以,如果在后续不需要使用旧策略的情况下,可以不为新策略分配空间.
1.2.3 策略迭代
策略迭代是一种综合利用策略评估和策略改进求解最优策略的迭代方法.
见算法 1-3 ,策略迭代从一个任意的确定性策略 开始,交替进行策略评估和策略改进.这里的策略改进是严格的策略改进,即改进后的策略和改进前的策略是不同的 对于状态空间和动作空间均有限的 Markov 决策过程,其可能的确定性策略数是有限的.由于确定性策略总数是有限的,所以在迭代过程中得到的策略序列 一定能收敛,使得到某个 ,有 (即对任意的 均有 .由于在 的情况下, ,进而 ,满足 Bellman 最优方程.因此, 就是最优策略.这样就证明了策略迭代能够收敛到最优策略.
算法 1-3:有模型策略迭代
输入: 动力系统 输出:最优策略
- (初始化)将策略 初始化为一个任意的确定性策略.
- (迭代) 对于 , 执行以下步骤 2.1 (策略评估)使用策略评估算法,计算策略 的状态价值函数 2.2 (策略更新)利用状态价值函数 改进确定性策略 ,得到改进的确定性策略 .如果 .(即对任意的 均有 ),则迭代完成,返回策略 为最终的最优策略.
策略迭代也可以通过重复利用空间来节约空间.为了节约空间,在各次迭代中用相同的空间 来存储状态价值函数,用空间 来存储确定性策略.
1.3 有模型价值迭代
价值迭代是一种利用迭代求解最优价值函数进而求解最优策略的方法.在 节介绍的策略评估中,迭代算法利用 Bellman 期望方程迭代求解给定策略的价值函数.与之相对,本节将利用 Bellman 最优方程迭代求解最优策略的价值函数,并进而求得最优策略.
与策略评估的情形类似,价值迭代算法有参数来控制迭代的终止条件,可以是误差容忍度 或是最大迭代次数 .
算法 1-4 给出了一个价值迭代算法.这个价值迭代算法中先初始化状态价值函数,然后用 Bellman 最优方程来更新状态价值函数.根据第 1.1 节的证明,只要迭代次数足够多,最终会收敘到最优价值函数.得到最优价值函数后,就能很轻易地给出确定性的最优策略.
算法 1-4: 有模型价值迭代算法
输入:动力系统 愉出:最优策略估计 参数:策略评估需要的参数
- (初始化) 任意值, .如果有终止状态,
- (迭代) 对于 ,执行以下步骤 2.1 对于 ,逐一更新 2.2 如果满足误差容忍度 (即对于 均有 或达到最大迭代次数 (即 ,则跳出循环
- (策略) 根据价值函数输出确定性策略 ,使得
与策略评估的迭代求解类似,价值迭代也可以在存储状态价值函数时重复使用空间.算法 1-5 给出了重复使用空间以节约空间的版本.
算法 1-5:有模型价值迭代 (节约空间的版本 )
输入: 动力系统 输出:最优策略 参数:策略评估需要的参数
- (初始化) 任意值, .如果有终止状态,
- (迭代) 对于 ,执行以下步骤
2.1 对于使用误差容忍度的情况,初始化本次迭代观测到的最大误差
2.2 对于 执行以下操作:
- 计算新状态价值
- 对于使用误差容忍度的情况,更新本次迭代观测到的最大误差
- 更新状态价值函数 2.3 如果满足误差容忍度(即 或达到最大迭代次数 即 ,则跳出循环
- (策略) 根据价值函数输出确定性策略:
二. 回合更新价值迭代
本章开始介绍无模型的机器学习算法.无模型的机器学习算法在没有环境的数学描述的情况下,只依靠经验(例如轨迹的样本) 学习出给定策略的价值函数和最优策略.在现实生活中,为环境建立精确的数学模型往往非常困难.因此,无模型的强化学习是强化学习的主要形式.
根据价值函数的更新时机,强化学习可以分为回合更新算法和时序差分更新算法这两类.回合更新算法只能用于回合制任务,它在每个回合结束后更新价值函数.本章将介绍回合更新算法,包括同策回合更新算法和异策回合更新算法.
2.1 同策回合更新
本节介绍同策回合更新算法.与有模型迭代更新的情况类似,我们也是先学习同策策略评估,再学习最优策略求解.
2.1.1 同策回合更新策略评估
本节考虑用回合更新的方法学习给定策略的价值函数.我们知道,状态价值和动作价值分别是在给定状态和状态动作对的情况下回报的期望值.回合更新策略评估的基本思路使用 Monte Carlo 方法来估计这个期望值.具体而言,在许多轨迹样本中,如果某个状态(或状态动作对) 出现了 次,其对应的回报值分别为 ,那么可以估计其状态价 (或动作价值) 为 .
无模型策略评估算法有评估状态价值函数和评估动作价值函数两种版本.在有模型情况下,状态价值和动作价值可以互相表示;但是在无模型的情况下,状态价值和动作价值并不能互相表示.我们已经知道,任意策略的价值函数满足 Bellman 期望方程.借助于动力 (某个状态转移分布)的表达式,我们可以用状态价值函数表示动作价值函数;借助于策略 的表达式,我们可以用动作价值函数表示状态价值函数.所以,对于无模型的策略评估, 的表达式未知,只能用动作价值表示状态价值,而不能用状态价值表示动作价值.另外,由于策略改进可以仅由动作价值函数确定,因此在学习问题中,动作价值函数往往更加重要.
在同一个回合中,多个步骤可能会到达同一个状态 (或状态动作对),即同一状态(或状态动作对)可能会被多次访问.对于不同次的访问,计算得到的回报样本值很可能不相 同.如果采用回合内全部的回报样本值更新价值函数, 则称为每次访问回合更新(every visit Monte Carlo update); 如果每个回合只采用第一次访问的回报样本更新价值函数,则称为首次访问回合更新( first visit Monte Carlo update).每次访问和首次访问在学习过程中的中间值并不相同,但是它们都能收敛到真实的价值函数.
首先来看每次访问回合更新策略评估算法.算法 2-1 给出了每次访问更新求动作价值的算法.我们来逐步看一下算法 2-1 .算法 2-1 首先对动作价值 进行初始化. 可以初始化为任意的值,因为在第一次更新后 的值就和初始化的值没有关系,所以将 初始化为什么数无关紧要.接着,算法 2-1 进行回合更新.与有模型迭代更新的情形类似,这里可以用参数来控制回合更新的回合数.例如,可以使用最大回合数 或者精度指标 .在生成好轨迹后,算法 2-1 采用逆序的方式更新 .这里采用逆序是为了使用 这一关系来更新 值,以减小计算复杂度.
算法 2-1:每次访问回合更新评估策略的动作价值
输入:环境(无数学描述),策略 输出:动作价值函数
- (初始化) 初始化动作价值估计 任意值, ,若更新价值需要使用计数 器,则初始化计数器 。
- (回合更新) 对于每个回合执行以下操作 2.1 (采样) 用策略 生成轨迹 2.2 (初始化回报) 2.3 (逐步更新)对 执行以下步骤 1. (更新回报) 2. (更新动作价值)更新 以减小 (如 , .
算法 2-1 在更新动作价值时,可以采用增量法来实现 Monte Carlo 方法.增量法的原理如下:如前 次观察到的回报样本是 ,则前 次价值函数的估计值为 ;如果第 次的回报样本是 ,则前 次价值函数的估计值为 可以证明, .所以,只要知道出现的次数 ,就可以用新的观测 把旧的平均值 更新为新的平均值 .因此,增量法不仅需要记录当前的价值估计 还需要记录状态动作对出现的次数 .在算法 2-1 中,状态动作对 的出现次数记录在 里,每次更新时将计数值加 1,再更新平均值 ,这样就实现了增量法.
求得动作价值后,可以用 Bellman 期望方程求得状态价值.状态价值也可以直接用回合更新的方法得到.算法 2-2 给出了每次访问回合更新评估策略的状态价值的算法.它与算法 2-1 的区别在于将 替换为了 ,计数也相应做了修改.
算法 4-2: 每次访问回合更新评估策略的状态价值
输入: 环境(无数学描述),策略 输出:状态价值函数
- (初始化)初始化状态价值估计 任意值, ,若更新价值时需要使用计数器则更新初始化计数器
- (回合更新)对于每个回合执行以下操作
2.1 (采样)用策略 生成轨迹
2.2 (初始化回报) 。
2.3 (逐步更新) 对 执行以下步骤 :
- (更新回报)
- (更新状态价值)更新 以减小 如 ,
首次访问回合更新策略评估是比每次访问回合更新策略评估更为历史悠久、更为全面研究的算法.算法 2-3 给出了首次访问回合更新求动作价值的算法.这个算法和算法 2-1 的区别在于,在每次得到轨迹样本后,先找出各状态分别在哪些步骤被首次访问.在后续的更新过程中,只在那些首次访问的步骤更新价值函数的估计值.
算法 2-3:首次访问回合更新评估策略的动作价值
输入: 环境(无数学描述),策略 输出:动作价值函数 .
- (初始化)初始化动作价值估计 任意值, ,若更新动作价值时需要计数器,则初始化计数器 .
- (回合更新)对于每个回合执行以下操作
2.1 (采样)用策略 生成轨迹
2.2 (初始化回报)
2.3 (初始化首次出现的步骤数)
2.4 (统计首次出现的步骤数)对于 ,执行以下步骤:如果 ,则
2.5 (逐步更新)对 ,执行以下步骤:
- (更新回报)
- (首次出现则更新)如果 ,则更新 以减小 如.
与每次访问的情形类似,首次访问也可以直接估计状态价值,见算法 2-4 .当然也可借助 Bellman 期望方程用动作价值求得状态价值.
算法 2-4:首次访问回合更新评估策略的状态价值
输入:环境(无数学描述),策略 输出:状态价值函数
- (初始化)初始化状态价值估计 任意值, ,若更新价值时需要使用计数器,更新初始化计数器
- (回合更新)对于每个回合执行以下操作
2.1 (采样)用策略 生成轨迹
2.2 (初始化回报)
2.3 (初始化首次出现的步骤数)
2.4 (统计首次出现的步骤数)对于 ,执行以下步骤:如果 ,则
2.5 (逐步更新)对 ,执行以下步骤 :
- (更新回报)
- (首次出现则更新)如果 ,则更新 以减小 如 ,.
TODO:起始探索与柔性策略
2.2 异策回合更新
本节考虑异策回合更新.异策算法允许生成轨迹的策略和正在被评估或被优化的策路不是同一策略.我们将引人异策算法中一个非常重要的概念——重要性采样,并用其进行 策略评估和求解最优策略.
2.2.1 重要性采样
在统计学上,重要性采样(importance sampling)是一种用一个分布生成的样本来估计另一个分布的统计量的方法.在异策学习中,将要学习的策略 称为目标策略(target policy),将用来生成行为的另一策略 称为行为策略(behavior policy ).重要性采样可以用行为策略生成的轨迹样本生成目标策略的统计量.
现在考虑从 开始的轨迹 .在给定 的条件下,采用 策略 和策略 生成这个轨迹的概率分别为: 我们把这两个概率的比值定义为重要性采样比率 (importance sample ratio): 这个比率只与轨迹和策略有关,而与动力无关.为了让这个比率对不同的轨迹总是有意义,我们需要使得任何满足 的 ,均有 这样的关系可以记为.
对于给定状态动作对 的条件概率也有类似的分析.在给定 的条件下,采用策略 和策略 生成这个轨迹的概率分别为: 其概率的比值为 回合更新总是使用 Monte Carlo 估计价值函数的值.同策回合更新得到 个回报 后,用平均值 来作为价值函数的估计.这样的方法实际上默认了这 个回报是等概率出现的.类似的是,异策回合更新用行为策略 得到 个回报 ,这个回报值对于行为策略 是等概率出现的.但是这 个回报值对于目标策略 不是等概率出现的.对于目标策略 而言,这 个回报值出现的概率正是各轨迹的重要性采样比率.这样,我们可以用加权平均来完成 Monte Carlo 估计.具体而言,若 是回报样本 对应的权重(即轨迹的重要性采样比率),可以有以下两种加权方法.
- 加权重要性采样(weighted importance sampling ), 即
- 普通重要性采样(ordinary importance sampling), 即
这两种方法的区别在于分母部分.对于加权重要性采样,如果某个权重 ,那么它不会让对应的 参与平均,并不影响整体的平均值;对于普通重要性采样,如果某个权重 ,那么它会让 0 参与平均,使得平均值变小.无论是加权重要性采样还是普通重要性采样,当回报样本数增加时,仍然可以用增量法将旧的加权平均值更新为新的加权平均值.对于加权重要性采样,需要将计数值替换为权重的和,以 的形式作更新.对于普通重要性采样而言,实际上就是对 加以平均,与直接没有加权情况下对 加以平均没有本质区别.它的更新形式为 :
2.2.2 异策回合更新策略评估
基于 2.2.1 节给出的重要性采样,算法 2-7 给出了每次访问加权重要性采样回合更新策略评估算法.这个算法在初始化环节初始化了权重和 与动作价值 ,然后进行回合更新.回合更新需要借助行为策略 .行为策略 可以每个回合都单独设计,也可以为整个算法设计一个行为策略,而在所有回合都使用同一个行为策略.用行为策略生成轨迹样本后,逆序更新回报、价值函数和权重值.一开始权重值 设为 1 ,以后会越来越小.如果某次权重值变为 0 (这往往是因为 ,那么以 后的权重值就都为 0 ,再循环下去没有意义.所以这里设计了一个检查机制.事实上,这个检查机制保证了在更新 时权重和 是必需的.如果没有检查机制,则可能在更新 时,更新前和更新后的 值都是 0 ,进而在更新 时出现除零错误.加这个检查机制避免了这样的错误.
算法 2-7: 每次访问加权重要性采样异策回合更新评估策略的动作价值
- (初始化)初始化动作价值估计 任意值, ,如果需要使用权重和,则初始化权重和
- (回合更新)对每个回合执行以下操作
2.1 (行为策略)指定行为策略 ,使得
2.2 (采样)用策略 生成轨迹:
2.3 (初始化回报和权重)
2.4 对于 执行以下操作:
- (更新回报)
- (更新价值)更新 以减小 如 ,
- (更新权重)
- (提前终止)如果 ,则结東步骤 2.4 的循环
在算法 2-7 的基础上略作修改,可以得到首次访问的算法、普通重要性采样的算法和估计状态价值的算法,此处略过.
2.2.3 异策回合更新最优策略求解
接下来介绍最优策略的求解.算法 2-8 给出了每次访问加权重要性采样异策回合最优策略求解算法.它和其他最优策略求解算法一样,都是在策略估计算法的基础上加上策略改进得来的.算法 2-8 的迭代过程中,始终让 是一个确定性策略.所以,在回合更新的过程中,任选一个策略 都满足 .这个柔性策略可以每个回合都分别选取,也可以整个程序共用一个.由于采用了确定性的策略,则对于每个状态 都有一个 使得 ,而其他 .算法 2-8 利用这一性质来更新权重并判断权重是否为 0 .如果 ,则意味着 ,更新后的权重为 0 ,需要退出循环以避免除零错误;若 ,则意味着 ,所以权重更新语句 就可以简化为 .
算法 2-8: 每次访问加权重要性采样异策回合更新最优策略求解
- (初始化)初始化动作价值估计 任意值, ,如果需要使用权重和,初始化权重和
- (回合更新)对每个回合执行以下操作
2.1 (柔性策略)指定 为任意柔性策略
2.2 (采样)用策略 生成轨迹:
2.3 (初始化回报和权重)
2.4 对
- (更新回报)
- (更新价值)更新 以减小 如 ,
- (策略更新)
- (提前终止)若 则退出步骤 2.4
- (更新权重)
算法 2-8 也可以修改得到首次访问的算法和普通重要性采样的算法,此处略过.
三. 时序差分价值迭代
本章介绍另外一种学习方法一时序差分更新.时序差分更新和回合更新都是直接采用经验数据进行学习,而不需要环境模型.时序差分更新与回合更新的区别在于,时序差 分更新汲取了动态规划方法中"自益"的思想,用现有的价值估计值来更新价值估计,不需要等到回合结束也可以更新价值估计.所以,时序差分更新既可以用于回合制任务,也可以用于连续性任务.
本章将介绍时序差分更新方法,包括同策时序差分更新方法和异策时序差分更新方法, 每种方法都先介绍简单的单步更新,再介绍多步更新.最后,本章会涉及基于资格迹的学习算法.
3.1 同策时序差分更新
本节考虑无模型同策时序差分更新.与无模型回合更新的情况相同,在无模型的情况下动作价值比状态价值更为重要,因为动作价值能够决定策略和状态价值,但是状态价值得不到动作价值.
本节考虑无模型同策时序差分更新。与无模型回合更新的情况相同,在无模型的情况下动作价值比状态价值更为重要,因为动作价值能够决定策略和状态价值,但是状态价值得不到动作价值。 从给定策略 的情况下动作价值的定义出发,我们可以得到下式: 在上一章的回合更新学习中,我们依据 ,用 Monte Carlo 方法来估计价值函数.为了得到回报样本,我们要从状态动作对 出发一直采样到回合结束.单步时序差分更新将依据 ,只需要采样一步,进而用 ,来估计回报样本的值.为了与由奖励直接计算得到的无偏回报样本 进行区别,本书用字母 表示使用自益得到的有偏回报样本.
基于以上分析,我们可以定义时序差分目标.时序差分目标可以针对动作价值定义,也可以针对状态价值定义.对于动作价值,其单步时序差分目标定义为 其中 的上标 表示是对动作价值定义的,下标 表示用 的估计值来估计 .如果 是终止状态,默认有 .这样的时序差分目标可以进一步扩展到多步的情况. n 步时序差分目标 定义为 在不强调步数的情况下, 可以简记为 或 .对于回合制任务,如果回合的步数 ,则我们可以强制让 这样,上述时序差分目标的定义式依然成立,实际上 达到了 的效果。本书后文都做这样的假设。类似的是,对于 状态价值,定义 步时序差分目标为 它也可以简记为 或 .
3.1.1 时序差分更新策略评估
本节考虑利用时序差分目标来评估给定策略的价值函数.回顾在同策回合更新策略评估中,我们用形如 的增量更新来学习动作价值函数,试图减小 .在这个式子中, 是回报样本.在时序差分中,这个量就对应着 .因此,只需在回合更新策略评估算法的基础上,将这个增量更新式中的回报 替换为时序差分目标 ,就可以得到时序差分策略评估算法了.
时序差分目标既可以是单步时序差分目标,也可以是多步时序差分目标.我们先来看单步时序差分目标.
算法 3-1 给出了用单步时序差分更新评估策略的动作价值的算法.这个算法有一个 ,它是一个正实数,表示学习率.在上一章的回合更新中,这个学习率往往是 ,它和状态动作对有关,并且不断减小.在时序差分更新中,也可以采用这样不断减小的学习率.不过,考虑到在时序差分算法执行的过程中价值函数会越来越准确,进而基于价值函数估计得到的价值函数也会越来越准确,因此估计值的权重可以越来越大.所以,算法 3-1 采用了一个固定的学习率 .这个学习率一般在 .当然,学习率也可以不是常数.在有些问题中,让学习率巧妙地变化能得到更好的效果.引入学习率 后,更新式可以表示为:
算法 3-1: 单步时序差分更新评估策略的动作价值
输入: 环境(无数学描述)、策略 输出:动作价值函数 参数:优化器(隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, .如果有终止状态,令 .
- (时序差分更新)对每个回合执行以下操作
2.1 (初始化状态动作对)选择状态 ,再根据输入策略 确定动作
2.2 如果回合未结東(例如未达到最大步数、S不是终止状态), 执行以下操作:
- (采样)执行动作 ,观测得到奖励 和新状态
- 用输入策略 确定动作
- (计算回报的估计值)
- (更新价值)更新 以减小 如
- .
在具体的更新过程中,除了学习率 和折扣因子 外,还有控制回合数和每个回合步数的参数.我们知道,时序差分更新不仅可以用于回合制任务,也可以用于非回合制任务.对于非回合制任务,我们可以自行将某些时段抽出来当作多个回合,也可以不划分回合当作只有一个回合进行更新.类似地,算法 3-2 给出了用单步时序差分方法评估策略状态价值的算法.
算法 3-2 单步时序差分更新评估策略的状态价值
输入:环境(无数学描述)、策略 输出:状态价值函数 参数:优化器(隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化) 任意值, .如果有终止状态,
- (时序差分更新)对每个回合执行以下操作
2.1 (初始化状态)选择状态
2.2 如果回合未结東(例如未达到最大步数、S不是终止状态),执行以下操作:
- 根据输入策略 确定动作
- (采样)执行动作 ,观测得到奖励 和新状态
- (计算回报的估计值)
- (更新价值)更新 以减小 如
在无模型的情况下,用回合更新和时序差分更新来评估策略都能渐近得到真实的价值函数.它们各有优劣.目前并没有证明某种方法就比另外一种方法更好.根据经验,学习 率为常数的时序差分更新常常比学习率为常数的回合更新更快收敛.不过时序差分更新对环境的 Markov 性要求更高.
我们通过一个例子来比较回合更新和时序差分更新.考虑某个 Markov 奖励过程,我们得到了它的 5 个轨迹样本如下(只显示状态和奖励): 使用回合更新得到的状态价值估计值为 ,而使用时序差分更新得到的状态价值估计值为 .这两种方法对 的估计是一样的,但是对于 的估计有明显不同:回合更新只考虑其中两个含有 的轨迹样本,用这两个轨迹样本回报来估计状态价值;时序差分更新认为状态 下一步肯定会到达状态 ,所以可以利用全部轨迹样本来估计 ,进而由 推出 .试想,如果这个环境真的是 Markov 决策过程,并且我们正确地识别出了状态空间 ,那么时序差分更新方法可以用更多的轨迹样本来帮助估计 的状态价值,这样可以更好地利用现有的样本得到更精确的估计.但是,如果这个环境其实并不是 Markov 决策过程,或是 并不是其真正的状态空间.那么也有可能 之后获得的奖励值其实和这个轨迹是否到达过 有关,例如如果达到过 则奖励总是为 0 .这种情况下,回合更新能够不受到这一错误的影响,只采用正确的信息,从而不受无关信息的干扰,得到正确的估计.这个例子比较了回合更新和时序差分更新的部分利弊。
接下来看如何用多步时序差分目标来评估策略价值.算法 3-3 和算法 3-4 分别给出了用多步时序差分评估动作价值和状态价值的算法.实际实现时,可以让 和 共享同一存储空间,这样只需要 份存储空间.
算法 3-3 步时序差分更新评估策略的动作价值
输入:环境(无数学描述)、策略 输出:动作价值估计 参数:步数 ,优化器 (隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化) 任意值 .如果有终止状态,令
- (时序差分更新)对每个回合执行以下操作
2.1 (生成 n 步)用策略 生成轨迹 (若遇到终止状态,则令后续奖励均为0,状态均为
2.2 对于 依次执行以下操作,直到 :
- 若 ,则根据 决定动作
- (更新时序差分目标)
- (更新价值)更新 以减小
- 若 ,则执行 ,得到奖励 和下一状态 ;若 ,
算法 3-4 步时序差分更新评估策略的状态价值
输入: 环境(无数学描述)、策略 输出:状态价值估计 参数:步数 ,优化器(隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化) 任意值, 如果有终止状态,令
- (时序差分更新) 对每个回合执行以下操作
2.1(生成 步)用策略 生成轨迹 (若遇到终止状态,则令后续奖励均为 0 ,状态均为 ).
2.2 对于 依次执行以下操作,直到 :
- (更新时序差分目标 )
- (更新价值)更新 以减小
- 若 ,则根据 决定动作 并执行,得到奖励 和下一状态 ;若 ,令 .
3.1.2 SARSA算法
本节我们采用同策时序差分更新来求解最优策略.首先我们来看 “状态 / 动作 / 奖励 状态 / 动作”(State-Action-Reward-State-Action, SARSA)算法.这个算法得名于更新涉及的随机变量 .该算法利用 得到单步时序差分目标 ,进而更新 .该算法的更新式为: 其中 是学习率.算法 3-5 给出了用 SARSA 算法求解最优策略的算法.SARSA 算法就是在单步动作价值估计的算法的基础上,在更新价值估计后更新策略.在算法 3-5 中,每当最优动作价值函数的估计 更新时,就进行策略改进,修改最优策略的估计 .策略的提升方法可以采用 贪心算法,使得 总是柔性策略.更新结束后,就得到最优动作价值估计和最优策略估计.
算法 3-5 SARSA 算法求解最优策略(显式更新策略)
与 Q-learning 区别在于,SARSA 每次算得的 在下一步会使用,也就是这一步通过 Q 函数预测得到的下一步采取的动作 也是在下一步更新时真正使用的,而 Q-learning 在下一步更新时的 是重新算的(用这一步更新后的 Q 函数算得)
输入:环境(无数学描述) 输出:最优策略估计 和最优动作价值估计 参数:优化器(隐含学习率 ),折扣因子 ,策略改进的参数(如 ),其他控制回合数和回合步数的参数
- (初始化) 任意值, .如果有终止状态,令 .用动作价值 确定策略 如使用 贪心策略
- (时序差分更新)对每个回合执行以下操作
2.1 (初始化状态动作对)选择状态 ,再用策略 确定动作
2.2 如果回合未结東(比如未达到最大步数、 不是终止状态),执行以下操作:
- (采样)执行动作 ,观测得到奖励 和新状态
- 用策略 确定动作
- (计算回报的估计值)
- (更新价值)更新 以减小 如
- (策略改进)根据 修改 如 贪心策略
其实,在同策迭代的过程中,最优策略也可以不显式存储.另外多步 SARSA 此处省略.
3.1.3 期望SARSA算法
SARSA 算法有一种变化一一期望 SARSA 算法(Expected SARSA).期望 SARSA算法与 SARSA 算法的不同之处在于,它在估计 时,不使用基于动作价值的时序差分目标 ,而使用基于状态价值的时序差分目标 .利用 Bellman 方程,这样的目标又可以表示为 与 SARSA 算法相比,期望 SARSA 需要计算 ,所以计算量比 SARSA 大.但是,这样的期望运算减小了 SARSA 算法中出现的个别不恰当决策.这样,可以避免在更新后期极个别不当决策对最终效果带来不好的影响.因此,期望 SARSA 常常有比 SARSA 更大的学习率.在很多情况下,期望 SARSA 的效果会比 SARSA 稍微好一些.
算法 3-6 给出了期望 SARSA 求解最优策略的算法,它可以视作在单步时序差分状态价值估计算法上修改得到的.期望 SARSA 对回合数和回合内步数的控制方法等都和 SARSA 相同,但是由于期望 SARSA 在更新 时不需要 ,所以其循环结构有所简化.算法中让 保持为 柔性策略.如果 很小,那么这个 柔性策略就很接近于确定性策略,则期望 SARSA 计算的 就很接近于 .
算法 3-6 期望 SARSA 求解最优策略
- (初始化) 任意值, .如果有终止状态,令 .用动作价值 确定策略 (如使用 贪心策略)
- (时序差分更新)对每个回合执行以下操作
2.1 (初始化状态)选择状态
2.2 如果回合未结東(比如未达到最大步数、S不是终止状态),执行以下操作:
- 用动作价值 确定的策略(如 贪心策略)确定动作
- (采样)执行动作 ,观测得到奖励 和新状态
- (用期望计算回报的估计值)
- (更新价值)更新 以减小 (如
3.2 异策时序差分更新
本节介绍异策时序差分更新.异策时序差分更新是比同策差分更新更加流行的算法.特别是 Q 学习算法,已经成为最重要的基础算法之一.
3.2.1 基于重要性采样的异策算法
时序差分策略评估也可以与重要性采样结合,进行异策的策略评估和最优策略求解.对于 步时序差分评估策略的动作价值和 SARSA 算法,其时序差分目标 依赖于轨迹 .在给定 的情况下,采用策略 和另外的行为策略 生成这个轨迹的概率分别为: 它们的比值就是重要性采样比率: 也就是说,通过行为策略 拿到的估计,在原策略 出现的概率是在策略 中出现概率的 倍.所以,在学习过程中,这样的时序差分目标的权重为 .将这个权重整合到时序差分策略评估动作价值算法或 SARSA算法中,就可以得到它们的重要性采样的版本.算法 3-7 给出了多步时序差分的版本,单步版本请自行整理.
算法 3-7: 步时序差分策略评估动作价值或 SARSA 算法
输入:环境(无数学描述)、策略 输出:动作价值函数 ,若是最优策略控制则还要输出策略 参数:步数 ,优化器 (隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化) 任意值, .如果有终止状态,令 .若是最优策略控制,还应该用 决定 (如 贪心策略)
- (时序差分更新)对每个回合执行以下操作
2.1 (行为策略)指定行为策略 ,使得
2.2 (生成 步)用策略 生成轨迹 (若遇到终止状态,则令后续奖励均为 0 , 状态均为 )
2.3 对于 依次执行以下操作,直到 :
- 若 ,则根据 决定动作
- (更新时序差分目标 )
- (计算重要性采样比率 )
- (更新价值)更新 以减小
- (更新策略)如果是最优策略求解算法,需要根据 修改
- 若 ,则执行 ,得到奖励 和下一状态 ;若 ,则令 .
我们可以用类似的方法将重要性采样运用于时序差分状态价值估计和期望 SARSA 算法中.具体而言,考虑从 开始的 步轨迹 .在给定 的条件下,采用策略 和策略 生成这个轨迹的概率分别为: 它们的比值就是时序差分状态评估和期望 SARSA 算法用到的重要性采样比率:
3.2.2 Q学习
3.1.3 节的期望 SARSA 算法将时序差分目标从 SARSA 算法的 改为 ,从而避免了偶尔出现的不当行为给整体结果带来的负面影响. Q 学习则是从改进后策略的行为出发,将时序差分目标改为 Q 学习算法认为,在根据 估计 时,与其使用 或 ,还不如使用根据 改进后的策略来更新,毕竟这样可以更接近最优价值.因此 Q 学习的更新式不是基于当前的策略,而是基于另外一个并不一定要使用的确定性策略来更新动作价值.从这个意义上看,Q 学习是一个异策算法.算法 3-8 给出了 Q 学习算法.Q 学习算法和期望 SARSA 有完全相同的程序结构,只是在更新最优动作价值的估计 时使用了不同的方法来计算目标.
算法 3-8: Q 学习算法求解最优策略
- (初始化) 任意值, .如果有终止状态,令
- (时序差分更新)对每个回合执行以下操作
2.1 (初始化状态)选择状态
2.2 如果回合未结東(例如未达到最大步数、S不是终止状态),执行以下操作:
- 用动作价值估计 确定的策略决定动作 如 贪心策略
- (采样)执行动作 ,观测得到奖励 和新状态
- (用改进后的策略计算回报的估计值)
- (更新价值和策略)更新 以减小 如
当然,Q学习也有多步的版本,其目标为: 具体省略
3.2.3 双重Q学习
上一节介绍的 Q 学习用 来更新动作价值,会导致“最大化偏差 (maximization bias),使得估计的动作价值偏大.
我们来看一个最大化偏差的例子.下所示的回合制任务中,Markov决策过程的状态空间为 ,回合开始时总是处在 状态,可以选择的动作空间 .如果选择动作 ,则可以到达状态 ,该步奖励为 0 ;如果选择动作 ,则可以达到终止状态并获得奖励 .从状态 出发,有很多可选的动作(例如有 1000 个可选的动作),但是这些动作都指向终止状态,并且奖励都服从均值为 0 、方差为 100 的正态分布.从理论上说,这个例子的最优价值函数为: , ,最优策略应当是 .但是,如果采用 Q 学习,在中间过程中会走一些弯路:在学习过程中,从 出发的某些动作会采样到比较大的奖励值,从而导致 会比较大,使得从 .这样的错误需要大量的数据才能纠正.为了解决这一问题,双重 Q 学习 ( Double Q Learning)算法使用两个独立的动作价值估计值 和 ,用 或 来 代替 Q 学习中的 .由于 和 是相互独立的估计,所以 ,其中 ,这样就消除了偏差.在双重学习的过程中, 和 都需要逐渐更新.所以,每步学习可以等概率选择以下两个更新 中的任意一个:
- 使用 来更新 以减小 和 之间的差别 (例如设定损失为 ,或采用 更新
- 使用 来更新 ,以减小 和 之间的差别 (例如设定损失为 ,或采用 更新
算法 3-9 给出了双重 Q 学习求解最优策略的算法.这个算法中最终输出的动作价值函数是 和 的平均值,即 .在算法的中间步骤,我们用这两个估计的和 来代替平均值 ,在略微简化的计算下也可以达到相同的效果.
算法 3-9: 双重 Q 学习算法求解最优策略
- (初始化) 任意值, .如果有终止状态,则令
- (时序差分更新)对每个回合执行以下操作.
2.1 (初始化状态)选择状态
2.2 如果回合未结束(比如未达到最大步数、 不是终止状态),执行以下操作:
- 用动作价值 确定的策略决定动作 (如 贪心策略)
- (采样)执行动作 ,观测得到奖励 和新状态
- (随机选择更新 或 )以等概率选择 或 中的一个动作价值函数作为更新对象,记选择的是
- (用改进后的策略更新回报的估计)
- (更新动作价值)更新 以减小 如
3.3 资格迹
资格迹是一种让时序差分学习更加有效的机制.它能在回合更新和单步时序差分更新之间折中,并且实现简单,运行有效.
3.3.1 回报
在正式介绍资格迹之前,我们先来学习 回报和基于 回报的离线 回报算法.给定 , 回报 return 是时序差分目标 按 加权平均的结果.对于连续性任务,有 对于回合制任务,则有 回报 可以看作是回合更新中的目标 和单步时序差分目标 的推广:当 时, 就是回合更新的回报;当 时, 就是单步时序差分目标.
离线 回报算法( offline -return algorithm )则是在更新价值(如动作价值 或状态价值 时,用 作为目标,试图减小 或 .它与回合更新算法相比,只是将更新的目标从 换为了 .对于回合制任务,在回合结束后为每一步 计算 ,并统一更新价值.因此,这样的算法称为离线算法( offline algorithm ).对于连续性任务,没有办法计算 ,所以无法使用离线 算法.
由于离线 回报算法使用的目标在 和 间做了折中,所以离线 回报算法的效果可能比回合更新和单步时序差分更新都要好.但是,离线 回报算法也有明显的缺点: 其一,它只能用于回合制任务,不能用于连续性任务;其二,在回合结束后要计算 ,计算量巨大.在下一节我们将采用资格迹来弥补这两个缺点.
3.3.2 TD()
TD () 是历史上具有重要影响力的强化学习算法,在离线 回报算法的基础上改进而来.以基于动作价值的算法为例,在离线 回报算法中,对任意的 ,在更新 或 时,时序差分目标 的权重是 .虽然需要等到回合结束才能计算 ,但是在知道 后就能计算 .所以我们在知道 后,就可 以用 去更新所有的 ,并且更新的权重与 成正比.
据此,给定轨迹 ,可以引入资格迹 来表示第 步的状态动作对 的单步自益结果 对每个状态动作对 需要更新的权重.资格迹(eligibility)用下列递推式定义:当 时, 当 时, 其中 是事先给定的参数.资格迹的表达式应该这么理解:对于历史上的某个状念动作对 ,距离第 步间隔了 步, 在 回报 中的权重为 ,并且 ,所以 是以 的比率折算到 中.间隔的步数每增加一步,原先的资格迹大致需要衰减为 倍.对当前最新出现的状态动作对 ,大的更新权重则要进行某种强化.强化的强度 常有以下取值:
- , 这时的资格迹称为累积迹(accumulating trace)
- (其中 是学习率),这时的资格迹称为荷兰迹(dutch trace)
- ,这时的资格迹称为替换迹(replacing trace).
当 时,直接将其资格迹加 1 ;当 时,资格迹总是取值在 范围内,所以让其资格迹直接等于 1 也实现了增加,只是增加的幅度没有 时那么大;当 时,增加的幅度在 和 之间.

利用资格迹,可以得到 策略评估算法.算法 3-10 给出了用 评估动作价值的算法.它是在单步时序差分的基础上,加入资格迹来实现的.资格迹也可以和最优策略求解算法结合,例如和 算法结合得到 算法.算法 3-10 中如果没有策略输入,在选择动作时按照当前动作价值来选择最优动作,就是 算法.
算法 3-10 的动作价值评估或 学习
输入:环境(无数学描述),若评估动作价值则需输入策略 . 输出:动作价值估计 参数:资格迹参数 ,优化器(隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化)初始化价值估计 任意值, .如果有终止状态,令
- 对每个回合执行以下操作:
2.1 (初始化资格迹)
2.2 (初始化状态动作对)选择状态 ,再根据输入策略 确定动作
2.3 如果回合未结東(比如未达到最大步数、S不是终止状态),执行以下操作:
- (采样)执行动作 ,观测得到奖励 和新状态
- 根据输入策略 或是迭代的最优价值函数 确定动作
- (更新资格迹)
- (计算回报的估计值)
- (更新价值)
- 若 ,则退出 2.2 步;否则 .
资格迹也可以用于状态价值.给定轨迹 ,资格迹 来表示第 步的状态动作对 对的单步自益结果 对每个状态 需要更新的权重,其定义为:当 时, 当 时, 算法 3-11 给出了用资格迹评估策略状态价值的算法.
算法 5-14 更新评估策略的状态价值
输入:环境(无数学描述)、策略 输出:状态价值函数 参数:资格迹参数 ,优化器(隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化)初始化价值 任意值, .如果有终止状态,
- 对每个回合执行以下操作:
2.1 (初始化资格迹)
2.2 (初始化状态)选择状态 S.
2.3 如果回合未结東(比如未达到最大步数、 不是终止状态),执行以下操作:
- 根据输入策略 确定动作
- (采样)执行动作 ,观测得到奖励 和新状态
- (更新资格迹)
- (计算回报的估计值)
- (更新价值)
算法与离线 回报算法相比,具有三大优点:
- 算法既可以用于回合制任务,又可以用于连续性任务
- 算法在每一步都更新价值估计,能够及时反映变化
- 算法在每一步都有均匀的计算,而且计算量都较小
四. 函数近似方法
第 章中介绍的有模型数值迭代算法、回合更新算法和时序差分更新算法,在每次更新价值函数时都只更新某个状态(或状态动作对)下的价值估计.但是,在有些任务中,状态和动作的数目非常大,甚至可能是无穷大,这时,不可能对所有的状态 (或状态动作对) 逐一进行更新.函数近似方法用参数化的模型来近似整个状态价值函数(或动作价值函数),并在每次学习时更新整个函数.这样,那些没有被访问过的状态(或状态动作对)的价值估计也能得到更新.本章将介绍函数近似方法的一般理论,包括策略评估和最优策略求解的一般理论.再介绍两种最常见的近似函数:线性函数和人工神经网络.后者将深度学习和强化学习相结合,称为深度 Q 学习,是第一个深度强化学习算法,也是目前的热门算法.
4.1 函数近似原理
本节介绍用函数近似(function approximation)方法来估计给定策略 的状态价值函数 或动作价值函数 .要评估状态价值,我们可以用一个参数为 的函数 来近似状态价值;要评估动作价值,我们可以用一个参数为 的函数 来近似动作价值.在动作集 有限的情况下,还可以用一个矢量函数一个动作,而整个矢量函数除参数外只用状态作为输人.这里的函数 、 形式不限,可以是线性函数,也可以是神经网络.但是,它们的形式要事先给定,在学习过程中只更新参数 .一旦参数 完全确定,价值估计就完全给定.所以,本节将介绍如何更新参数 .更新参数的方法既可以用于策略价值评估,也可以用于最优策略求解.
4.1.1 随机梯度下降
本节来看同策回合更新价值估计.将同策回合更新价值估计与函数近似方法相结合,可以得到函数近似回合更新价值估计算法(算法 4-1 ).这个算法与第 2 章中回合更新算法的区别就是在价值更新时更新的对象是函数参数,而不是每个状态或状态动作对的价值估计.
算法 6-1: 随机梯度下降函数近似评估策略的价值
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (采样)用环境和策略 生成轨迹样本
2.2 (初始化回报)
2.3 (逐步更新)对 ,执行以下步骤
- (更新回报)
- (更新价值)若评估的是动作价值则更新 以减小 (如 若评估的是状态价值则更新 以减小 .
如果我们用算法 4-1 评估动作价值,则更新参数时应当试图减小每一步的回报估计 和动作价值估计 的差别.所以,可以定义每一步损失为 ,而整个回合的损失为 .如果我们沿着 对 的梯度的反方向更新策略参数 ,就有机会减小损失.这样的方法称为随机梯度下降( stochastic gradient-descent, SGD )算法.对于能支持自动梯度计算的软件包,往往自带根据损失函数更新参数的功能.如果不使用现成的参数更新软件包,也可以自己计算得到 的 梯度 ,然后利用下式进行更新 : 对于状态价值函数,也有类似的分析.定义每一步的损失为 ,整个回合的损失为 .可以在自动梯度计算并更新参数的软件包中定义这个损失来更新参数 , 也可以用下式更新: 相应的回合更新策略评估算法与算法 4-1 类似,此处从略.
将策略改进引入随机梯度下降评估策略,就能实现随机梯度下降最优策略求解.算法 4-2 给出了随机梯度下降最优策略求解的算法.它与第 2 章回合更新最优策略求解算法的区别也仅仅在于迭代的过程中不是直接修改价值估计,而是更新价值参数 .
算法 4-2: 随机梯度下降求最优策略
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (采样)用环境和当前动作价值估计 导出的策略(如 柔性策略)生成轨迹样
本
2.2 (初始化回报)
2.3 (逐步更新)对 ,执行以下步骤:
- (更新回报)
- (更新动作价值函数)更新参数 以减小 如 .
4.1.2 半梯度下降
动态规划和时序差分学习都用了“自益”来估计回报,回报的估计值与 有关,是存在偏差的.例如,对于单步更新时序差分估计的动作价值函数,回报的估计为 ,而动作价值的估计为 ,这两个估计都与权重 有关.在试图减小每一步的回报估计 和动作价值估计 的差别时,可以定义每一步损失为 ,而整个回合的损失为 .在更新参数 以减小损失时,应当注意不对回报的估计 求梯度,只对动作价值的估计 求关于 的梯度,这就是半梯度下降(semi-gradient descent)算法.半梯度下降算法同样既可以用于策略评估,也可以用于求解最优策略(见算法 4-3 和算法 4-4 ).
算法 4-3: 半梯度下降算法估计动作价值或 算法求最优策略
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (初始化状态动作对)选择状态 .如果是策略评估,则用输入策略 确定动作 ;如果是寻找最优策略,则用当前动作价值估计 导出的策略 如 柔性策略 确定动作
2.2 如果回合未结東,执行以下操作:
- (采样)执行动作 ,观测得到奖励 和新状态
- 如果是策略评估,则用输入策略 确定动作 ;如果是寻找最优策略,则用当前动作价值估计 导出的策略 如 柔性策略)确定动作
- (计算回报的估计值)
- (更新动作价值函数)更新参数 以减小 如 .注意此步不可以重新计算
算法 4-4: 半梯度下降估计状态价值或期望 SARSA 算法或 学习
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (初始化状态)选择状态
2.2 如果回合未结束,执行以下操作
- 如果是策略评估,则用输入策略 确定动作 ;如果是寻找最优策略,则用当前动作价值估计 导出的策略(如 柔性策略)确定动作
- (采样)执行动作 ,观测得到奖励 和新状态
- (计算回报的估计值)如果是状态价值评估,则 .如果是期望 SARSA 算法,则 ,其中 是 确定的策略(如 柔性策略)若是 学习则
- (更新动作价值函数)若是状态价值评估则更新 以减小 (如 ,若是期望 算法或 学习则更新参数 以减小 如 .注意此步不可以重新计算
- .
如果采用能够自动计算微分并更新参数的软件包来减小损失,则务必注意不能对回报的估计求梯度.有些软件包可以阻止计算过程中梯度的传播,也可以在计算回报估计的表达式时使用阻止梯度传播的功能.还有一种方法是复制一份参数 ,在计算回报估计的表达式时用这份复制后的参数 来计算回报估计,而在自动微分时只对原来的参数进行微分,这样就可以避免对回报估计求梯度.
4.1.3 带资格迹的半梯度下降
在第 3 章中,我们学习了资格迹算法.资格迹可以在回合更新和单步时序差分更新之间进行折中,可能获得比回合更新或单步时序差分更新都更好的结果.回顾前文,在资格迹算法中,每个价值估计的数值都对应着一个资格迹参数,这个资格迹参数表示这个价值估计数值在更新中的权重.最近遇到的状态动作对(或状态)的权重大,比较久以前遇到的状态动作对(或状态)的权重小,从来没有遇到过的状态动作对(或状态)的权重为 0 .每次更新时,都可以更新整条轨迹上的资格迹,再利用资格迹作为权重,更新整条轨迹上的价值估计.
资格迹同样可以运用在函数近似算法中,实现回合更新和单步时序差分的折中.这时,资格迹对应价值参数 .具体而言,资格迹参数 和价值参数 具有相同的形状大小,并且逐元素一一对应.资格迹参数中的每个元素表示了在更新价值参数对应元素时应当使用的权重乘以价值估计对该分量的梯度.也就是说,在更新价值参数 的某个分量 对应着资格迹参数 中的某个分量 时,那么在更新 时应当使用以下迭代式更新: 对价值参数整体而言,就有 当选取资格迹为累积迹时,资格迹的递推定义式如下:当 时 当 时 资格迹的递推式由 2 项组成.递推式的第一项是对前一次更新时使用的资格迹衰减而来,衰减系数是 ,这是一个 0 到 1 之间的数.可以通过改变 的值,决定衰减的速度. 当 接近 0 时,衰减快;当 接近 1 时,衰减慢.递推式的第二项是加强项,它由动作价值的梯度值决定.动作价值的梯度值事实上确定了价值参数对总体价值估计的影响.对总体价值估计影响大的那些价值参数分量是当前比较重要的分量,应当加强它的资格迹.不过,梯度的分量值不一定是正数或 0 ,也可能是负数.所以,更新后的资格迹分量也可能是负值.当资格迹的某些分量是负值时,对应价值参数分量的权重值就是负值.进一步而言,在价值参数更新时,面对相同的时序差分误差,会出现价值参数的某些分量增大而另一些 分量减小的情况. 算法 4-5 和算法 4-6 给出了使用资格迹的价值估计和最优策略求解算法.这两个算法都 使用了累积迹.
算法 6-5 算法估计动作价值或 算法
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (初始化状态动作对)选择状态 如果是策略评估,则用输入策略 确定动作 ;如果是寻找最优策略,则用当前动作价值估计 导出的策略(如 柔性策略 确定动作
2.2 如果回合未结東,执行以下操作
- (采样)执行动作 ,观测得到奖励 和新状态
- 如果是策略评估,则用输入策略 确定动作 ;如果是寻找最优策略,用当前动作价值估计 导出的策略 如 柔性策略 确定动作 ;
- (计算回报的估计值)
- (更新资格迹)
- (更新动作价值函数)
- .
算法 4-6 TD(\lambda) 估计状态价值或期望 SARSA(\lambda) 算法或 学习
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (初始化资格迹)
2.2 (初始化状态)选择状态 S
2.3 如果回合未结束,执行以下操作
- 如果是策略评估,则用输入策略 确定动作 ;如果是寻找最优策略,用当前动作价值估计 导出的策略 如 柔性策略 确定动作
- (采样)执行动作 ,观测得到奖励 和新状态
- (计算回报的估计值)如果是状态价值评估,则 .如果是期望 SARSA 算法,则 ,其中 是 确定的策略 (如 柔性策略 .若是 学习则
- (更新资格迹)若是状态价值评估,则 ;若是期望 法或 学习,则
- (更新动作价值函数)若是状态价值评估,则 ;若是期望 算法或 学习,则
- .
TODO:线性近似
TODO:4.3函数近似的收敛性
4.4 深度Q学习
本节介绍一种目前非常热门的函数近似方法——深度 Q 学习.深度 Q 学习将深度学习和强化学习相结合,是第一个深度强化学习算法.深度 Q 学习的核心就是用一个人工神经网络 来代替动作价值函数.由于神经网络具有强大的表达能力,能够自动寻找特征,所以采用神经网络有潜力比传统人工特征强大得多.最近基于深度 Q 网络的深度强化学习算法有了重大的进展,在目前学术界有非常大的影响力.当同时出现异策、自益和函数近似时,无法保证收敛性,会出现训练不稳定或训练困难等问题.针对出现的各种问题,研究人员主要从以下两方面进行了改进.
- 经验回放(experience replay): 将经验(即历史的状态、动作、奖励等)存储起来,再在存储的经验中按一定的规则采样.
- 目标网络(target network): 修改网络的更新方式,例如不把刚学习到的网络权重马上用于后续的自益过程.
本节后续内容将从这两条主线出发,介绍基于深度 Q 网络的强化学习算法.
4.4.1 经验回放
V. Mnih 等在 2013 年发表文章《Playing Atari with deep reinforcement learning 》,提出了基于经验回放的深度 Q 网络,标志着深度 Q 网络的诞生,也标志着深度强化学习的诞生.在 4.2 节中我们知道,采用批处理的模式能够提供稳定性.经验回放就是一种让经验的概率分布变得稳定的技术,它能提高训练的稳定性.经验回放主要有“存储”和“采样回放”两大关键步骤.
- 存储:将轨迹以 等形式存储起来;
- 采样回放:使用某种规则从存储的 中随机取出一条或多条经验
算法 4-8 给出了带经验回放的 Q 学习最优策略求解算法
算法 6-8 带经验回放的 学习最优策略求解
- (初始化)任意初始化参数
- 逐回合执行以下操作
2.1 (初始化状态)选择状态
2.2 如果回合未结東,执行以下操作
- (采样)根据 选择动作 并执行,观测得到奖励 和新状态
- (存储)将经验 存入经验库中
- (回放)从经验库中选取经验
- (计算回报的估计值)
- (更新动作价值函数)更新 以减小 (如
经验回放有以下好处.
- 在训练 网络时,可以消除数据的关联,使得数据更像是独立同分布的(独立同分布是很多有监督学习的证明条件)这样可以减小参数更新的方差,加快收敛.
- 能够重复使用经验,对于数据获取困难的情况尤其有用.
从存储的角度,经验回放可以分为集中式回放和分布式回放.
- 集中式回放:智能体在一个环境中运行,把经验统一存储在经验池中.
- 分布式回放:智能体的多份拷贝(worker) 同时在多个环境中运行,并将经验统一存储于经验池中.由于多个智能体拷贝同时生成经验,所以能够在使用更多资源的同 时更快地收集经验
从采样的角度,经验回放可以分为均匀回放和优先回放.
- 均匀回放:等概率从经验集中取经验,并且用取得的经验来更新最优价值函数
- 优先回放(Prioritized Experience Replay, PER):为经验池里的每个经验指定一个优先级,在选取经验时更倾向于选择优先级高的经验.
T. Schaul 等于 2016 年发表文章 《Prioritized experience replay》,提出了优先回放.优先回放的基本思想是为经验池里的经验指定一个优先级,在选取经验时更倾向于选择优先级高的经验.一般的做法是,如果某个经验(例如经验 )的优先级为 ,那么选取该经验的概率为 经验值有许多不同的选取方法,最常见的选取方法有成比例优先和基于排序优先.
- 成比例优先(proportional priority):第 个经验的优先级为 .其中 是时序差分误差 定义为 或 是预先选择 的一个小正数, 是正参数.
- 基于排序优先(rank-based priority): 第 个经验的优先级为 .其中 是第 个经验从大到小排序的排名,排名从 1 开始.
D. Horgan 等在 2018 发表文章 《 Distributed prioritized experience replay》,将分布式经 签回放和优先经验回放相结合,得到分布式优先经验回放(distributed prioritized experience replay).
4.4.2 带目标网络的深度Q学习
对于基于自益的 Q 学习,其回报的估计和动作价值的估计都和权重 有关.当权重值变化时,回报的估计和动作价值的估计都会变化.在学习的过程中,动作价值试图追逐一个变化的回报,也容易出现不稳定的情况.在 4.1.2 节中给出了半梯度下降的算法来解决 这个问题.在半梯度下降中,在更新价值参数 时,不对基于自益得到的回报估计 求梯 度.其中一种阻止对 求梯度的方法就是将价值参数复制一份得到 ,在计算 时用 计算.基于这一方法,V. Mnih 等在 2015 年发表了论文《Human-level control through deep reinforcement learning》,提出了目标网(target network)这一概念.目标网络是在原有的神经网络之外再搭建一份结构完全相同的网络.原先就有的神经网络称为评估网络(evaluation network).在学习的过程中,使用目标网络来进行自益得到回报的评估值,作为学习的目标.在权重更新的过程中,只更新评估网络的权重,而不更新目标网络的权重.这样,更新权重时针对的目标不会在每次迭代都变化,是一个固定的目标.在完成一定次数的更新后,再将评估网络的权重值赋给目标网络,进而进行下一批更新.这样,目标网络也能得到更新.由于在目标网络没有变化的一段时间内回报的估计是相对固定的,目标网络的引入增加了学习的稳定性.所以,目标网络目前已经成为深度 Q 学习的主流做法.
算法 4-9 给出了带目标网络的深度 Q 学习算法.算法开始时将评估网络和目标网络初始化为相同的值.为了得到好的训练效果,应当按照神经网络的相关规则仔细初始化神经网络的参数.
算法 4-9 带经验回放和目标网络的深度 学习最优策略求解
- (初始化)初始化评估网络 的参数 ;目标网络 的参数
- 逐回合执行以下操作
2.1 (初始化状态)选择状态
2.2 如果回合未结束,执行以下操作:
- (采样)根据 选择动作 并执行,观测得到奖励 和新状态
- (经验存储)将经验 存入经验库 中
- (经验回放)从经验库 中选取一批经验
- (计算回报的估计值)
- (更新动作价值函数)更新 以减小 (如
- (更新目标网络)在一定条件下(例如访问本步若千次)更新目标网络的权重
在更新目标网络时,可以简单地把评估网络的参数直接赋值给目标网络 即 ,也可以引人一个学习率 把旧的目标网络参数和新的评估网络参数直接做加权平均后的值赋值给目标网络 即 .事实上,直接赋值的版本是带学习率版本在 时的特例.对于分布式学习的情形,有很多独立的拷贝(worker)同时会修改目标网络,则就更常用学习率 .
4.4.3 双重深度Q学习
第 3 章曾提到 学习会带来最大化偏差,而双重 学习却可以消除最大化偏差.基于查找表的双重 Q 学习引入了两个动作价值的估计 和 ,每次更新动作价值时用其中的一个网络确定动作,用确定的动作和另外一个网络来估计回报.
对于深度 Q 学习也有同样的结论.Deepmind 于 2015 年发表论文 《 Deep reinforcement learning with double Q-learning 》,将双重 Q 学习用于深度 Q 网络,得到了双重深度 Q 网络(Double Deep Q Network, Double ).考虑到深度 Q 网络已经有了评估网络和目标网络两个网络,所以双重深度 Q 学习在估计回报时只需要用评估网络确定动作,用目标网络确定回报的估计即可.所以,只需要将算法 4-10 中的 更换为 就得到了带经验回放的双重深度 Q 网络算法.
4.4.4 对偶深度 Q 网络
Z. Wang 等在 2015 年发表论文《Dueling network architectures for deep reinforcement learning 》,提出了一种神经网络的结构——对偶网络( duel network).对偶网络理论利用动作价值函数和状态价值函数之差定义了一个新的函数——优势函数(advantage function): 对偶 网络仍然用 来估计动作价值,只不过这时候 是状态价值估计 和优 势函数估计 的叠加,即 其中 和 可能都只用到了 中的部分参数.在训练的过程中, 和 是共 同训练的,训练过程和单独训练普通深度 Q 网络并无不同之处.不过,同一个 事实上存在着无穷多种分解为 和 的方式.如果某个 可以分解为某个 和 ,那么它也能分解为 和 ,其中 是任意一个只和状态 有关的函数.为了不给训练带来不必要的麻烦,往往可以通过增加一个由优势函数导出的量,使得等效的优势函数满足固定的特征,使得分解唯一.常见的方法有以下两种:
- 考虑优势函数的最大值,令 使得等效优势函数 满足
- 考虑优势函数的平均值,令 使得等效优势函数 满足
五. 回合更新策略梯度方法
本书前几章的算法都利用了价值函数,在求解最优策略的过程中试图估计最优价值函数,所以那些算法都称为最优价值算法(optimal value algorithm).但是,要求解最优策略不一定要估计最优价值函数.本章将介绍不直接估计最优价值函数的强化学习算法,它们试图用含参函数近似最优策略,并通过迭代更新参数值.由于迭代过程与策略的梯度有关,所以这样的迭代算法又称为策略梯度算法(policy gradient algorithm).
5.1 策略梯度算法的原理
基于策略的策略梯度算法有两大核心思想:
- 用含参函数近似最优策略
- 用策略梯度优化策略参数
本节介绍这两部分内容.
5.1.1 函数近似与动作偏好
用函数近似方法估计最优策略 的基本思想是用含参函数 来近似最优策略.由于任意策略 都需要满足对于任意的状态 ,均有 ,我们也希望 满足对于任意的状态 ,均有 .为此引入动作偏好函数(action preference function) ,其 softmax 的值为 ,即 在第 3~4 章中,从动作价值函数导出最优策略估计往往有特定的形式 (如 贪心策 略).与之相比,从动作偏好导出的最优策略的估计不拘泥于特定的形式,其每个动作都可以有不同的概率值,形式更加灵活.如果采用迭代方法更新参数 ,随着迭代的进行, 可以自然而然地逼近确定性策略,而不需要手动调节 等参数.
动作偏好函数可以具有线性组合、人工神经网络等多种形式.在确定动作偏好的形式中,只需要再确定参数 的值,就可以确定整个最优状态估计.参数 的值常通过基于梯度的迭代算法更新,所以,动作偏好函数往往需要对参数 可导.
5.1.2 策略梯度定理
策略梯度定理给出了期望回报和策略梯度之间的关系,是策略梯度方法的基础.本节学习策略梯度定理.
在回合制任务中,策略 期望回报可以表示为 .策略梯度定理(policy gradient theorem) 给出了它对策略参数 的梯度为 其等式右边是和的期望,求和的 中,只有 显式含有参数 .
策略梯度定理告诉我们,只要知道了 的值,再配合其他一些容易获得的 值(如 和 ,就可以得到期望回报的梯度.这样,我们也可以顺着梯度方向改变 以增大期望回报.
接下来我们来证明这个定理.回顾,策略 满足 期望方程,即 将以上两式对 求梯度,有 将 的表达式代人 的表达式中,有 在策略 下,对 求上式的期望,有 这样就得到了从 到 的递推式.注意到最终关注的梯度值就是 所以有 考虑到 所以 又由于 ,所以 得证.
5.2 同策回合更新策略梯度算法
策略梯度定理告诉我们,沿着 的方向改变策略参数 的值,就有机会增加期望回报.基于这一结论,可以设计策略梯度算法.本节考虑同策更新算法
5.2.1 简单的策略梯度算法
在每一个回合结束后,我们可以就回合中的每一步用形如 的迭代式更新参数 .这样的算法称为简单的策略梯度算法(Vanilla Policy Gradient, VPG).
R Willims 在文章《Simple statistical gradient-following algorithms for connectionist reinforcement learning 》中给出了该算法,并称它为“REward Increment = Nonnegative Factor Offset Reinforcement Characteristic Eligibility” ( ,表示增量 是由三个部分的积组成的.这样迭代完这个回合轨迹就实现了 在具体的更新过程中,不一定要严格采用这样的形式.当采用 TensorFlow 等自动微分的软件包来学习参数时,可以定义单步的损失为 ,让软件包中的优化器减小整个回合中所有步的平均损失,就会沿着 的梯度方向改变 的值.
简单的策略梯度算法见算法 5-1.
算法 5-1: 简单的策略梯度算法求解最优策略
输入:环境(无数学描述) 输出:最优策略的估计 参数:优化器(隐含学习率 ),折扣因子 ,控制回合数和回合内步数的参数
- (初始化) 任意值
- (回合更新)对每个回合执行以下操作
2.1 (采样)用策略 生成轨迹
2.2 (初始化回报)
2.3 对 ,执行以下步骤:
- (更新回报)
- (更新策略)更新 以减小
5.2.2 带基线的简单策略梯度算法
本节介绍简单的策略梯度算法的一种改进一带基线的简单的策略梯度算法(REINFOCE with baselines).为了降低学习过程中的方差,可以引人基线函数 .基线函数 可以是任意随机函数或确定函数,它可以与状态 有关,但是不能和动作 有关.满足这样的条件后,基线函数 自然会满足 证明如下:由于 与 无关,所以 进而 得证. 基线函数可以任意选择,例如以下情况
- 选择基线函数为由轨迹确定的随机变量 ,这时 ,梯度的形式为
- 选择基线函数为 ,这时梯度的形式 为
但是,在实际选择基线时,应当参照以下两个思想.
- 基线的选择应当有效降低方差.一个基线函数能不能降低方差不容易在理论上判别, 往往需要通过实践获知.- 基线函数应当是可以得到的.例如我们不知道最优价值函数,但是可以得到最优价值函数的估计.价值函数的估计也可以随着迭代过程更新.
一个能有效降低方差的基线是状态价值函数的估计.算法 5-2 给出了用状态价值函数的估计作为基线的算法.这个算法有两套参数 和 ,分别是最优策略估计和最优状态价值函数估计的参数.每次迭代时,它们都以各自的学习算法进行学习.算法 5-2 采用了随机梯度下降法来更新这两套参数(事实上也可以用其他算法),在更新过程中都用到了 ,可以在更新前预先计算以减小计算量.
算法 5-2: 带基线的简单策略梯度算法求解最优策略
输入:环境(无数学描述) 输出:最优策略的估计 参数:优化器(隐含学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值.
- (回合更新)对每个回合执行以下操作:
2.1 (采样)用策略 生成轨迹
2.2 (初始化回报)
2.3 对 ,执行以下步骤:
- (更新回报)
- (更新价值)更新 以减小 如
- (更新策略)更新 以减小 如
接下来,我们来分析什么样的基线函数能最大程度地减小方差.考虑 的方差为 其对 求偏导数为 (求偏导数时用到了 ).令这个偏导数为 0 ,并假设 可知 这意味着,最佳的基线函数应当接近回报 以梯度 为权重加权平均的结果.但是,在实际应用中,无法事先知道这个值,所以无法使用这样的基线函数.
值得一提的是,当策略参数和价值参数同时需要学习的时候,算法的收敛性需要通过双时间轴 Robbins-Monro 算法(two timescale Robbins-Monro algorithm)来分析.
5.3 异策回合更新策略梯度算法
在简单的策略梯度算法的基础上引入重要性采样,可以得到对应的异策算法.记行为策略为 ,有 即 所以,采用重要性采样的离线算法,只需要把用在线策略采样得到的梯度方向 改为用行为策略 采样得到的梯度方向 即可.这就意味着,在更新参数 时可以试图增大 .
算法5-3: 重要性采样简单策略梯度求解最优策略
- (初始化) 任意值
- (回合更新)对每个回合执行以下操作:
2.1 (行为策略)指定行为策略 ,使得
2.2 (采样)用策略 生成轨迹:
2.3 (初始化回报和权重)
2.4 对 ,执行以下步骤:
- (更新回报)
- (更新策略)更新参数 以减小 如
重要性采样使得我们可以利用其他策略的样本来更新策略参数,但是可能会带来较大的偏差,算法稳定性比同策算法差.
5.4 策略梯度更新和极大似然估计的关系
至此,本章已经介绍了各种各样的策略梯度算法.这些算法在学习的过程中,都是通过更新策略参数 以试图增大形如 的目标(考虑单个条目则为 ,其中 可取 等值.将这一学习过程与下列有监督学习最大似然问题的过程进行比较,如果已经有一个表达式未知的策略 ,我们要用策略 来近似它,这时可以考虑用最大似然的方法来估计策略参数 .具体而言,如果已经用未知策略 生成了很多样本,那么这些样本对于策略 的对数似然值正比于 .用这些样本进行有监督学习,需要更新策略参数 以增大 (考虑单个条目则为 .可以看出, 可以通过 中取 得到,在形式上具有相似性.策略梯度算法在学习的过程中巧妙地利用观测到的奖励信号决定每步对数似然值 对策略奖励的贡献,为其加权 (这里的 可能是正数,可能是负数,也可 能是 0 ),使得策略 能够变得越来越好.注意,如果取 ,在整个回合中是不变的(例如 ,那么在单一回合中的 就是对整个回合的对数似然值进行加权后对策略的贡献,使得策略 能够变得越来越好.试想,如果有的回合表现很好 (比如 是很大的正数 ),在策略梯度更新的时候这个回合的似然值 就会有一个比较大的权重 例如 ,这样这个表现比较好的回合就会更倾向于出现;如果有的回合表现很差(比如 是很小的负数,即绝对值很大的负数)则策略梯度更新时这个回合的似然值就会有比较小的权重,这样这个表现较差的回合就更倾向于不出现.
六. 执行者/评论者方法
本章介绍带自益的策略梯度算法.这类算法将策略梯度和自益结合了起来:一方面,用一个含参函数近似价值函数,然后利用这个价值函数的近似值来估计回报值;另一方面,利用估计得到的回报值估计策略梯度,进而更新策略参数.这两方面又常常被称为评论者 (critic) 和执行者(actor).所以,带自益的策略梯度算法被称为执行者 / 评论者算法(actorcritic algorithm).
6.1 执行者 / 评论者算法
同样用含参函数 表示偏好,用其 运算的结果 来近似最优策略.在更新参数 时,执行者 / 评论者算法依然也是根据策略梯度定理,取 为梯度方向迭代更新.其中, Schulman 等在文章《 High-dimensional continuous control using generalized advantage estimation 》中指出, 并不拘泥于以上形式. 可以是以下几种形式:
- (动作价值)
- (优势函数)
- (时序差分)
在以上形式中,往往用价值函数来估计回报.例如,由于 ,而且 也表征期望方向,所以 ,相当于用 表示期望.再例如,对于 ,就相当于在回报 的基础上减去基线 以减小方差.对于时序差分 ,也是用 代表回报,再减去基线 以减小方差.
不过在实际使用时,真实的价值函数是不知道的.但是,我们可以去估计这些价值函数.具体而言,我们可以用函数近似的方法,用含参函数 或 来近似 和 .在上一章中,带基线的简单策略梯度算法已经使用了含参函数 作为基线函数.我们可以在此基础上进一步引人自益的思想,用价值的估计 来代替 中表示回报的部分.例如,对于时序差分,用估计来代替价值函数可以得到 .这里的估计值 就是评论者,这样的算法就是执行者 / 评论者算法. >注意:只有采用了自益的方法,即用价值估计来估计回报,并引入了偏差,才是执行者 / 评论者算法.用价值估计来做基线并没有带来偏差(因为基线本来就可以任意选择).所以,带基线的简单策略梯度算法不是执行者 / 评论者算法.
6.1.1 动作价值执行者 / 评论者算法
根据前述分析,同策执行者 / 评论者算法在更新策略参数 时也应该试图减小 ,只是在计算 时采用了基于自益的回报估计.算法 6-1 给出了在回报估计为 ,并取 时的同策算法,称为动作价值执行者 / 评论者算法.算法一开始初始化了策略参数和价值参数.虽然算法中写的是可以将这个参数初始化为任意值,但是如果它们是神经网络的参数,还是应该按照神经网络的要求来初始化参数.在迭代过程中有个变量 ,用来存储策略梯度的表达式中的折扣因子 .在同一回合中,每一步都把这个折扣因子乘上 ,所以第 步就是 .
算法 6-1: 动作价值同策执行者 / 评论者算法
输入:环境(无数学描述) 输出:最优策略的估计 参数:优化器(隐含学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值
- (带自益的策略更新)对每个回合执行以下操作:
2.1 (初始化累积折扣)
2.2 (决定初始状态动作对)选择状态 ,并用 得到动作
2.3 如果回合未结束,执行以下操作:
- (采样)根据状态 和动作 得到奖励 和下一状态
- (执行)用 得到动作
- (估计回报)
- (策略改进)更新 以减小 如
- (更新价值)更新 以减小 如
- (更新累积折扣)
- (更新状态) .
6.1.2 优势执行者 / 评论者算法
在基本执行者 / 评论者算法中引入基线函数 ,就会得到 ,其中, 是优势函数的估计.这样,我们就得到了优势执行者 评论者算法.不过,如果采用 这样形式的优势函数估计值,我们就需搭建两个函数分别表示 和 .为了避免这样的麻烦,这里用了 做目标,这样优势函数的估计就变为单步时序差分的形式 .
如果优势执行者 / 评论者算法在执行过程中不是每一步都更新参数,而是在回合结束后用整个轨迹来进行更新,就可以把算法分为经验搜集和经验使用两个部分.这样的分隔可以让这个算法同时有很多执行者在同时执行.例如,让多个执行者同时分别收集很多经验,然后都用自己的那些经验得到一批经验所带来的梯度更新值.每个执行者在一定的时机更新参数,同时更新策略参数 和价值参数 .每个执行者的更新是异步的.所以,这样的并行算法称为异步优势执行者 / 评论者算法( Asynchronous Advantage Actor-Critic, ).异步优势执行者 / 评论者算法中的自益部分,不仅可以采用单步时序差分,也可以使用多步时序差分.另外,还可以对函数参数的访问进行控制,使得所有执行者统一更新参数.这样的并行算法称为优势执行者 / 评论者算法(Advantage Actor-Critic, ).算法 给出了异步优势执行者 / 评论者算法.异步优势执行者 / 评论者算法可以有许多执行者 (或称多个线程 ),所以除了有全局的价值参数 和策略参数 外,每个线程还可能有自己维护的价值参数 和 .执行者执行时,先从全局同步参数,然后再自己学习,最后统一同步全局参数.
算法 6-2: 优势执行者 / 评论者算法
输入:环境(无数学描述) 输出:最优策略的估计 参数:优化器(隐含学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值.
- (带自益的策略更新)对每个回合执行以下操作:
2.1 (初始化累积折扣)
2.2 (决定初始状态)选择状态
2.3 如果回合未结束,执行以下操作:
- (采样)用 得到动作
- (执行)执行动作 ,得到奖励 和观测
- (估计回报)
- (策略改进)更新 以减小 如
- (更新价值)更新 以减小 如
- (更新累积折扣)
- (更新状态).
算法 6-3: 异步优势执行者 / 评论者算法 (演示某个线程的行为)
输入:环境(无数学描述) 输出:最优策略的估计 参数:优化器(隐含学习率 ,折扣因子 ,控制回合数和回合内步数的参数
- (同步全局参数)
- 逐回合执行以下过程:
2.1 用策略 生成轨迹 ,直到回合结束或执行步数达
到上限
2.2 为梯度计算初始化:
- (初始化目标 )若 是终止状态,则 ;否则
- (初始化梯度) 2.3 (异步计算梯度)对 ,执行以下内容:
- (估计目标)计算
- (估计策略梯度方向)
- (估计价值梯度方向)
- (同步更新)更新全局参数 3.1 (策略更新)用梯度方向 更新策略参数 如 3.2 (价值更新)用梯度方向 更新价值参数 如 .
6.1.3 带资格迹的执行者 / 评论者方法
执行者 / 评论者算法引入了自益,那么它也就可以引入资格迹.算法 6-4 给出了带资格迹的优势执行者 / 评论者算法.这个算法里有两个资格迹 和 ,它们分别与策略参数 和价值参数 对应,并可以分别有自己的 和 .具体而言, 与价值参数 对应,运用梯度为 ,参数为 的累积迹; 与策略参数 对应,运用的梯度是 参数为 的累积迹,在运用中可以将折扣 整合到资格迹中.
算法 6-4: 带资格迹的优势执行者 / 评论者算法
输入:环境(无数学描述) 输出:最优策略的估计 参数:资格迹参数 ,学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值.
- (带自益的策略更新)对每个回合执行以下操作:
2.1 (初始化资格迹和累积折扣)
2.2 (决定初始状态)选择状态
2.3 如果回合未结束,执行以下操作:
- (采样)用 得到动作
- (执行)执行动作 ,得到奖励 和观测
- (估计回报)
- (更新策略资格迹)
- (策略改进)
- (更新价值资格迹)
- (更新价值)
- (更新累积折扣)
- (更新状态) .
6.2 基于代理优势的同策算法
本节介绍面向代理优势的执行者 / 评论者算法.这些算法在迭代的过程中并没有直接优化期望目标,而是试图优化期望目标近似一代理优势.在很多问题上,这些算法会比简单的执行者 / 评论者算法得到更好的性能.
6.2.1 代理优势
考虑采用迭代的方法更新策略 .在某次迭代后,得到了策略 .接下来我们希望得到一个更好的策略 .Kakade 等在文章 《 Approximately optimal approximate reinforcement learning 》中证明了策略 和策略 的期望回报满足性能差别引理 (Performance Difference Lemma): (证明: 得证.)
所以,要最大化 ,就是要最大化优势的期望 .这个期望是对含参策略而言的.要优化这样的期望,可以利用以下形式的重采样,将其中对 求期望转化为对 求期望: 但是,对 求期望无法进一步转化.代理优势( surrogate advantage)就是在上述重采样的基础上,将对 求期望近似为对 求期望: 这样得到了 的近似表达式 ,其中 可以证明, 和 在 处有相同的值 和梯度.
虽然 没有直接的表达式而很难直接优化,但是只要沿着它的梯度方向改进策略参数,就有机会增大它.由于 和 在 处有着相同的值和梯度方向, 和代理优势有着相同的梯度方向.所以,沿着 的梯度方向就有机会改进 .据此,我们可以得到以下结论:通过优化代理优势,有希望找到更好的策略.
6.2.2 邻近策略优化
我们已经知道代理优势与真实的目标相比,在 处有相同的值和梯度.但是,如果 和 差别较远,则近似就不再成立.所以针对代理优势的优化不能离原有的策略太远.基于这一思想,J. Schulman 等在文章 《 Proximal policy optimization algorithms 》中提出了邻近策略优化 (Proximal Policy Optimization) 算法,将优化目标设计为 其中 是指定的参数.采用这样的优化目标后,优化目标至多比 大 ,所以优化问题就没有动力让代理优势 变得非常大,可以避免迭代后的策略与迭代前的策略差距过大.
算法 6-5 给出了邻近策略优化算法的简化版本.
算法 6-5: 邻近策略优化算法 (简化版本 )
输入:环境(无数学描述) 输出:最优策略的估计 参数:策略更新时目标的限制参数 ,优化器,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值
- (时序差分更新)对每个回合执行以下操作: 2.1 用策略 生成轨迹 2.2 用生成的轨迹由 确定的价值函数估计优势函数 如 2.3 (策略更新)更新 以增大 2.4 (价值更新)更新 以减小价值函数的误差(如最小化
在实际应用中,常常加人经验回放.具体的方法是,每次更新策略参数 和价值参数 前得到多个轨迹,为这些轨迹的每一步估计优势和价值目标,并存储在经验库 中.接着多次执行以下操作:从经验库 中抽取一批经验 ,并利用这批经验回放并学习,即从经验库中随机抽取一批经验并用这批经验更新策略参数和价值参数.
注意:邻近策略优化算法在学习过程中使用的经验都是当前策略产生的经验,所以使用了经验回放的邻近策略优化依然是同策学习算法.
6.3 信任域算法
信任域方法(Trust Region Method, TRM)是求解非线性优化的常用方法,它将一个复杂的优化问题近似为简单的信任域子问题再进行求解.
本节将介绍三种同策执行者 / 评论者算法:
- 自然策略梯度算法
- 信任域策略优化算法
- Kronecker 因子信任域执行者 / 评论者算法
这三个算法十分接近,它们都是以试图通过优化代理优势,迭代更新策略参数,进而找到最优策略的估计.在优化的过程中,也需要让新的策略和旧的策略不能相差太远.和上节介绍的邻近策略优化相比,它们在代理优势的基础上可以进一步引入信任域,要求新的策略在一个信任域内.本节将介绍信任域的定义(包括用来定义信任域的 散度的定义),再介绍如何利用信任域实现这些算法.
6.3.1 KL 散度
我们先来看 散度的定义.回顾重要性采样的章节,我们知道,如果两个分布 和 ,满足对于任意的 ,均有 ,则称分布 对 分布 绝对连续,记为 .在这种情况下,我们可以定义从分布 到分布 的 KL 散度 (Kullback-Leibler divergence): 当 和 是离散分布时, 当 和 是连续分布时, 散度有个性质:相同分布的 散度为 0 ,即 .
TODO:信任域A-C算法
重要性采样异策执行者 / 评论者算法
执行者 / 评论者算法可以和重要性采样结合,得到异策执行者 / 评论者算法.本节介绍基于重要性采样的异策执行者 / 评论者算法.
6.4.1 基本的异策算法
本节介绍基于重要性采样的异策的执行者/评论者算法(Off-Policy Actor-Critic, OffPAC ).
用 表示行为策略,则梯度方向可由 变为 .这时,更新策略参数 时就应该试图减小 .据此,可以得到异策执行者 / 评论者算法,见算法 6-10.
算法 8-10: 异策动作价值执行者 / 评论者算法
输入:环境(无数学描述) 输出:最优策略的估计 参数:优化器(隐含学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值
- (带自益的策略更新)对每个回合执行以下操作:
2.1 (初始化累积折扣)
2.2 (初始化状态动作对)选择状态 ,用行为策略 得到动作
2.3 如果回合未结束,执行以下操作:
- (采样)根据状态 和动作 得到采样 和下一状态
- (执行)用 得到动作
- (估计回报)
- (策略改进)更新 以减小 如
- (更新价值)更新 以减小 如
- (更新累积折扣)
- (更新状态)
6.4.2 带经验回放的异策算法
本节介绍 Wang 等在文章 《 Sample efficient actor-critic with experience replay》中提出的带经验回放的执行者 / 评论者算法 ( Actor-Critic with Experiment Replay, .如果说 节介绍的基本异策执行者 / 评论者算法是 节介绍的基本同策执行者 / 评论者算法的异策版本,那么本节介绍的带经验回放的异策执行者 / 评论者算法就相当于 节介绍的 算法的异策版本.它同样可以支持多个线程的异步学习:每个线程在执行前先同步全局参数,然后独立执行和学习,再利用学到的梯度方向进行全局更新.
6.1.2 节中介绍的执行者 / 评论者算法是基于整个轨迹进行更新的.对于引入行为策略和重采样后,对于目标 的重采样系数变为 , 其中 .在这个表达式中,每个 都有比较大的方差,最终乘积得到的方差会特别大.一种限制方差的方法是控制重采样比例的范围,例如给定一个常数 ,将重采样比例截断为 .但是,如果直接将梯度方向中的重采样系数进行截断(例如从 修改为 ),会带来偏差.这时候我们可以再加一项来弥补这个偏差.利用恒等式 ,我们可以把梯度 拆成以下两项: 期望针对行为策略 ,此项方差是可控的; 即 采用针对原有目标策略 的期望后, 也是有界的 (即 ).
采用这样的拆分后,两项的方差都是可控的.但是,这两项中其中一项针对的是行为策略,另外一项针对的是原策略,这就要求在执行过程中兼顾这两种策略.
得到梯度方向后,我们希望对这个梯度方向做修正,以免超出范围.为此,用 散度增加了约束。记在迭代过程中策略参数的指数滑动平均值为 ,对应的平均策略为 .我 们可以希望迭代得到的新策略参数不要与这个平均策略 参数差别太大.所以,可以限定这两个策略在当前状态 下的动作分布不要差别太大.考虑到 KL 散度可以刻画两个分布直接的差别,所以可以限定新得到的梯度方向(记为 ) 与 的内积不要太大.值得一提的是, 实际上有和重采样比例类似的形式: 至此,我们可以得到一个确定新的梯度方向的优化问题.记新的梯度方向为 ,定义 我们一方面希望新的梯度方向 要和 尽量接近,另外一方面要满足 ,不超过一个给定的参数 .这样这个优化问题为 接下来求解这个优化问题.使用 Lagrange 乘子法,构造函数: 将前式代人后式可得 .由于 Lagrange 乘子应大等于 0 ,所以,Lagrange 乘子应为 ,优化问题的最优解为 这个方向才是我们真正要用的梯度方向.综合以上分析,我们可以得到带经验回放的执行者 / 评论者算法的一个简化版本.这个算法可以有一个回放因子,可以控制每次运行得到的经验可以回放多少次.算法 6-11 给出了经验回放的线程的算法.对于经验回放的线程所回放的经验是从其他线程已经执行过的线程生成并存储的,这个过程在算法 6-11 中没有展示,但是是这个算法必需的.在存储和回放的时候,不仅要存储和回放状态 , 动作 、奖励 等,还需要存储和回放在状态 产生动作 的概率 .有了这个概率值,才能计算重采样系数.在价值网络的设计方面,只维护动作价值网络.在需要状态价值的估计时,由动作价值网络计算得到.
算法 6-11: 带经验回放的执行者 / 评 论者算法 (异策简化版本)
参数: 学习率 ,指数滑动平均系数 ,重采样因子截断系数 ,折扣因子 ,控制回合数和回合内步数的参数.
- (同步全局参数)
- (经验回放)回放存储的经验轨迹 ,以及经验对应的行为策略概率
- 梯度估计
3.1 为梯度计算初始化:
- (初始化目标 )若 是终止状态,则 ;否则
- (初始化梯度) 3.2 (异步计算梯度) 对 ,执行以下内容:
- (估计目标)计算
- (估计价值梯度方向)
- (估计策略梯度方向)计算动作价值 ,重采样系数 以及
- (更新回溯目标)
- (同步更新)更新全局参数. 4.1 (价值更新) 4.2 (策略更新) 4.3 (更新平均策略)
TODO:柔性A-C
七. 连续动作空间的确定性策略
简单易懂:b站搬运ShuSenWang教程
如何理解:两个网络:策略网络与价值函数网络( 函数 ) , 时刻,先利用策略时序差分地更新价值函数,再更新策略网络,策略网络的梯度下降想法是:参数朝着使 函数增大的方向走,即 函数关于策略网络的参数求梯度,所以最后推得的关系式策略网络的更新式形式为连式法则的样子 .
本章介绍在连续动作空间里的确定性执行者 / 评论者算法.在连续的动作空间中,动作的个数是无穷大的.如果采用常规方法,需要计算 .而对于无穷多的动作,最大值往往很难求得.为此,D. Silver 等人在文章《 Deterministic Policy Gradient Algorithms 》中提出了确定性策略的方法,来处理连续动作空间情况.本章将针对连续动作空间,推导出确定性策略的策略梯度定理,并据此给出确定性执行者 / 评论者算法.
7.1 同策确定性算法
对于连续动作空间里的确定性策略, 并不是一个通常意义上的函数,它对策略参数 的梯度. 也不复存在.所以,第 6 章介绍的执行者 / 评论者算法就不再适用.幸运的是,曾提到确定性策略可以表示为 .这种表示可以绕过由于 并不是通常意义上的函数而带来的困难.
本节介绍在连续空间中的确定性策略梯度定理,并据此给出基本的同策确定性执行者 / 评论者算法.
7.1.1 策略梯度定理的确定性版本
当策略是一个连续动作空间上的确定性的策略 时,策略梯度定理为 (证明:状态价值和动作价值满足以下关系 以上两式对 求梯度,有 将 的表达式代人 的表达式中,有 对上式求关于 的期望,并考虑到 (其中 任取),有 这样就得到了从 到 的递推式.注意,最终关注的梯度值就是 所以有 就得到和之前梯度策略定理类似的形式 ).
对于连续动作空间中的确定性策略,更常使用的是另外一种形式: 其中的期望是针对折扣的状态分布 (discounted state distribution) 而言的。(证明: 得证.)
7.1.2 基本的同策确定性执行者 / 评论者算法
根据策略梯度定理的确定性版本,对于连续动作空间中的确定性执行者 / 评论者算法,梯度的方向变为 确定性的同策执行者 评论者算法还是用 来近似 .这时, 近似为 所以,与随机版本的同策确定性执行者 / 评论者算法相比,确定性同策执行者 / 评论者算法在更新策略参数 时试图减小 .迭代式可以是 算法 7-1 给出了基本的同策确定性执行者 / 评论者算法.对于同策的算法,必须进行探索.连续性动作空间的确定性算法将每个状态都映射到一个确定的动作上,需要在动作空间添加扰动实现探索.具体而言,在状态 下确定性策略 指定的动作为 ,则在同策算法中使用的动作可以具有 的形式,其中 是扰动量.在动作空间无界的情况下(即没有限制动作有最大值和最小值),常常假设扰动量 满足正态分布.在动作空间有界的情况下,可以用 clip 函数进一步限制加扰动后的范围(如 ,其中 和 是动作的最小取值和最大取值),或用 sigmoid 函数将对加扰动后的动作变换到合适的区间里 如 .
算法 7-1: 基本的同策确定性执行者 / 评论者算法
输入: 环境(无数学描述) 输出:最优策略的估计 参数:学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值,任意值
- (带自益的策略更新)对每个回合执行以下操作:
2.1 (初始化累积折扣)
2.2 (初始化状态动作对)选择状态 ,对 加扰动进而确定动作 (如用正态分布随机变量扰动)
2.3 如果回合未结束,执行以下操作:
- (采样)根据状态 和动作 得到采样 和下一状态
- (执行)对 加扰动进而确定动作
- (估计回报)
- (更新价值)更新 以减小 如
- (策略改进)更新 以减小 如
- (更新累积折扣)
- (更新状态) .
在有些任务中,动作的效果经过低通滤波器处理后反映在系统中,而独立同分布的 Gaussian 噪声不能有效实现探索.例如,在某个任务中,动作的直接效果是改变一个质点的加速度.如果在这个任务中用独立同分布的 Gaussian 噪声叠加在动作上,那么对质点位置的整体效果是在没有噪声的位置附近移动.这样的探索就没有办法为质点的位置提供持续的偏移,使得质点到比较远的位置.在这类任务中,常常用 Ornstein Uhlenbeck 过 程作为动作噪声.Ornstein Uhlenbeck 过程是用下列随机微分方程定义的 (以一维的情况为例 ) 其中 是参数 是标准 Brownian 运动.当初始扰动是在原点的单点分布(即限定 ),并且 时,上述方程的解为 (证明:将 代入 化简可得 .将此式从 0 积到 ,得 .当 且 时化简可得结果).
这个解的均值为 0 ,方差为 ,协方差为 (证明:由于均值为 0 ,所以 .另外,Ito Isometry 告诉我们 ,所以 ,进一步化简可得结果.)
对于 总有 ,所以 .据此可知,使用 Ornstein Uhlenbeck 过程让相邻扰动正相关,进而让动作向相近的方向偏移.
7.2 异策确定性算法
对于连续的动作空间,我们希望能够找到一个确定性策略,使得每条轨迹的回报最大.同策确定性算法利用策略 生成轨迹,并在这些轨迹上求得回报的平均值,通过让平均回报最大,使得每条轨迹上的回报尽可能大.事实上,如果每条轨迹的回报都要最大,那么对于任意策略 采样得到的轨迹,我们都希望在这套轨迹上的平均回报最大.所以异策确定性策略算法引入确定性行为策略 ,将这个平均改为针对策略 采样得到的轨迹,得到异策确定性梯度为 这个表达式与同策的情形相比,期望运算针对的表达式相同.所以,异策确定性算法的迭代式与同策确定性算法的迭代式相同.
异策确定性算法可能比同策确定性算法性能好的原因在于,行为策略可能会促进探索,用行为策略采样得到的轨迹能够更加全面的探索轨迹空间.这时候,最大化对轨迹分布的平均期望时能够同时考虑到更多不同的轨迹,使得在整个轨迹空间上的所有轨迹的回报会更大.
7.2.1 基本的异策确定性执行者 / 评论者算法
基于上述分析,我们可以得到异策确定性执行者 / 评论者算法 (Off-Policy Deterministic Actor-Critic, ),见算法 7-2 .
值得一提的是,虽然异策算法和同策算法有相同形式的迭代式,但是在算法结构上并不完全相同.在同策算法迭代更新时,目标策略的动作可以在运行过程中直接得到;但是在异策算法迭代更新策略参数时,对环境使用的是行为策略决定的动作,而不是目标策略决定的动作,所以需要额外计算目标策略的动作.在更新价值函数时,采用的是 学习,依然需要计算目标策略的动作.
算法 9-2: 基本的异策确定性执行者 / 评论者算法
输入:环境(无数学描述) 输出:最优策略的估计 参数:学习率 ,折扣因子 ,控制回合数和回合内步数的参数.
- (初始化) 任意值, 任意值.
- (带自益的策略更新)对每个回合执行以下操作:
2.1 (初始化累积折扣)
2.2 (初始化状态)选择状态
2.3 如果回合未结束,执行以下操作:
- (执行)用 得到动作
- (采样)根据状态 和动作 得到采样 和下一状态
- (估计回报)
- (更新价值)更新 以减小 如
- (策略改进)更新 以减小 (如
- (更新累积折扣)
- (更新状态) .
7.2.2 深度确定性策略梯度算法
深度确定性策略梯度算法(Deep Deterministic Policy Gradient, )将基本的异策 确定性执行者 / 评论者算法和深度 Q 网络中常用的技术结合.具体而言,确定性深度策略梯度算法用到了以下技术.
- 经验回放:执行者得到的经验 收集后放在一个存储空间中,等更新参数时批量回放,用批处理更新.
- 目标网络:在常规价值参数 和策略参数 外再使用一套用于估计目标的目标价值参数 和目标策略参数 在更新目标网络时,为了避免参数更新过快,还引入了目标网络的学习率
算法 7-3 给出了深度确定性策略梯度算法.
算法 7-3: 深度确定性策略梯度算法 (假设 总是在动作空间内)
输入:环境(无数学描述) 输出:最优策略的估计 参数:学习率 ,折扣因子 ,控制回合数和回合内步数的参数,目标网络学习率
- (初始化) 任意值, 任意值,
- 循环执行以下操作:
2.1 (累积经验)从起始状态 出发,执行以下操作,直到满足终止条件:
- 用对 加扰动进而确定动作 (如用正态分布随机变量扰动)
- 执行动作 ,观测到收益 和下一状态
- 将经验 存储在经验存储空间 2.2 (更新)在更新的时机,执行一次或多次以下更新操作:
- (回放)从存储空间 采样出一批经验
- (估计回报)为经验估计回报
- (价值更新)更新 以减小
- (策略更新)更新 以减小 (如
- (更新目标)在恰当的时机更新目标网络和目标策略, .
7.2.3 双重延迟深度确定性策略梯度算法
S. Fujimoto 等人在文章 《 Addressing function approximation error in actor-critic methods》 中给出了双重延迟深度确定性策略梯度算法(Twin Delay Deep Deterministic Policy Gradient, TD3 ),结合了深度确定性策略梯度算法和双重 Q 学习.
回顾前文,双重 学习可以消除最大偏差.基于查找表的双重 Q 学习用了两套动作价值函数 和 ,其中一套动作价值函数用来计算最优动作(如 ,另外一套价值函数用来估计回报(如 ;双重 网络则考虑到有了目标网络后已经有了两套价值函数的参数 和 所以用其中一套参数 计算最优动作(如 ),再用目标网络的参数 估计目标 (如 ). 但是对于确定性策略梯度算法,动作已经由含参策略 决定了 (如 ),双重网络则要由双重延迟深度确定性策略梯度算法维护两份学习过程的价值网络参数 和目标网络参数 .在估计目标时,选取两个目标网络得到的结果中较小的那个,即 .
算法 7-4 给出了双重延迟深度确定性策略梯度算法.
算法 7-4 :双重延迟深度确定性策略梯度
输入: 环境(无数学描述) 榆出:最优策略的估计 参数:学习率 ,折扣因子 ,控制回合数和回合内步数的参数,目标网络学习率 .
- (初始化) 任意值, 任意值,
- 循环执行以下操作:
2.1 (累积经验)从起始状态 出发,执行以下操作,直到满足终止条件:
- 用对 加扰动进而确定动作 (如用正态分布随机变量扰动)
- 执行动作 ,观测到收益 和下一状态
- 将经验 存储在经验存储空间 2.2 (更新)一次或多次执行以下操作:
- (回放)从存储空间 采样出一批经验
- (扰动动作)为目标动作 加受限的扰动,得到动作
- (估计回报)为经验估计回报
- (价值更新)更新 以减小
- (策略更新)在恰当的时机,更新 以减小 如
- (更新目标)在恰当的时机,更新目标网络和目标策略, .
TODO:AlphaZero算法
RL 在金融中的应用
参见Modern Perspectivs on RL in Finance和RL in economics and finance 2021.
本来应该通过动态规划方法解这些问题.用动态规划解优化问题通常需要下述三个条件:
- 明确知道模型的状态转移概率
- 有足够的算力来求解DP
- Markov性质
RL,结合了DP,蒙特卡洛模拟,函数近似和机器学习.
RL在金融中主要有以下三个应用方向:
- 衍生品定价/对冲
- 投资组合/资产配置
- 做市
一. RL for Risk Management
通常而言,学术中对衍生品定价和对冲都是基于随机环境下的有模型决策(model-driven decision rules in a stochastic environment),常规对冲策略都会用到希腊值 Greeks,代表模型对不同参数风险定价的敏感程度.
这种方法在高维情况时通常缺少有效的数值模拟方法.
Deep Hedging
参考Deep Hedging, Buehler et al.
市场摩擦(market frictions):指金融资产在交易中存在的难度,如手续费(transaction costs)、买卖价差(bid/ask spread)、流动性约束(liquidity constraints)等.
本文中的对冲的对象是对冲掉一些衍生品的投资组合.
把 trading decision 建模成一个网络,特征不仅仅有价格,还有交易信号,新闻分析(news analytics),过去对冲决策等等.
算法是完全 model-free,不依赖对应市场的动力.我们只需确定下来市场的状态生成(scenario generator),损失函数,市场摩擦和交易行为(trading instruments).所以此方法 lends itself to a statistically driven market dynamics,我们不需要像传统方法那样计算单个衍生品的希腊值,应将建模的精力花在实现真实的市场动力和样本外表现.
建模:带市场摩擦的离散市场
考虑有限时域的离散金融市场 和交易时刻 . 固定一个有限概率空间 和一个概率测度 s.t. 对所有的 . 定义所有 上的实值随机变量 .
记 在 available 的新市场数据, including market costs and mid-prices of liquid instruments-typically quoted in auxiliary terms such as implied volatilities-news, balance sheet information, any trading signals, risk limits etc. 过程 生成域流 , i.e. 表示到 时刻所有可用的信息. 注意到每个 可测的随机变量可以写成 的函数.
市场有 个hedging instruments with mid-prices 取值于 -valued -adapted 随机过程 . 即可以用来做对冲的资产.
衍生品的投资组合即负债(liability)是一个 可测的随机变量 . 到期日 是所有衍生品种最大的那一个.此为想要对冲掉的东西.
即 用.
想要在 对冲掉 , 我们要用 -valued -adapted stochastic process with 来交易 . 表示智能体在 时刻对第 个资产的持有. 同样定义 .
记 是这样的交易策略的无约束集合. 但是每个 有其交易约束. 可能来自 liquidity, asset availability or trading restrictions. They are also used to restrict trading in a particular option prior to its availability. In the example above of an option which is listed in , the respective trading constraints would be until the point. 所以我们假定 受 约束,由一个连续 可测映射给出 , i.e.
对无约束策略 , 我们定义它在 的有约束投影 .记 为受约束的交易策略相应的非空集.
EXAMPLE 1 Assume that are a range of options and that computes the Black-Scholes Vega of each option using the various market parameters available at time . The overall Vega traded with is then A liquidity limit of a maximum tradable Vega of could then be implemented by the map:
对冲
交易是自融资的.不带交易费用的 时刻最终财富为 , 其中 当考虑交易费用时,在 时刻买入 的 股票a产生费用 . 策略总的交易费用为 回忆 .所以智能体总的花费变为
DRL
期权
期权基础知识
一.基础概念
1.1 BS公式(Delta对冲下)
BS部分参考知乎专栏:Black-Scholes 模型学习框架
假设股价满足 自融资资产组合(此处为期权价格的一个复制)价值变化为 对 ( 时刻衍生品的价值)做Ito公式,比较项的系数得到Delta 对冲下 的 BS 偏微分方程 (在比较系数过程中会使用到替换为,即为持有的债券份额为自融资总份额减去持有的标的份额,标的的持有量已经使用为) 终值条件(看涨期权)为 解即为 BS 公式 注意到 的漂移项 对期权定价没有影响.
BS公式解法(欧式call)
有考虑边界条件
注意到这是一个 Cauchy-Euler 方程,能通过下述变量代换将其转化为一个扩散方程 The solution of the PDE gives the value of the option at any earlier time, Black-Scholes PDE 变为一个扩散方程: 终值条件 现在变为了初值条件 其中 是 Heaviside 阶梯函数,
使用解给定了初值函数 的扩散方程的标准卷积法,得到 经过处理,得到: 其中 N 为正态分布累计密度函数,
BS model给出了期权价格的函数,作为一个波动率的函数.可以通过这个公式在给定期权价格时计算隐含波动率(implied volatility).但事实是 BS 波动率强烈依赖于欧式期权的到期日和行权价.
波动率微笑是指给定到期日下,隐含波动率与行权价(maturity)的关系.
1.2 Delta对冲
我们现在考虑用衍生品 和其标的资产 构建一个“无风险组合”,考虑这样的自融资组合 ,即每一单位的空头衍生品,我们用 单位多头的股票对其进行对冲 (Hedging),由于其自融资的特性,根据定义,我们有 ,将股价的 SDE 和上一节中通过伊藤-德布林公式求出的 带入这个式子,我们可以得到: 因为要使资产组合为无风险的, >一个自融资组合 如果是无风险的,则可以表示为 ,且 . 1式即 Delta 对冲法则,将 带入2式我们再次得到 BlackScholes 偏微分方程: .
1.3 BS公式(风险中性定价下)
1.3.1 鞅
定义 在 上的随机过程 , ,称其是关于域流 的鞅,如果满足:
- 是 -适应的 (adapted);
- ;
- 对于 ,有 .
1.3.2 Radon-Nikodym导数
定义 设 和 为 上的等价测度,若 ,a.s.,且有 , 则称 是 关于 的 Radon-Nikodym 导数,记作: .
,即 ,其中 表示在测度 下的期望.进一步的,可以用条件期望定义R-N导数过程: .用鞅和RN导数过程的定义,可以简单的证明,R-N导数过程 是一个 -鞅.
1.3.3 资产的现值
用 表示风险资产的价值过程.首先要知道,在 模型的假设下,市场是完备 (Complete) 的,即任意资产 都可以被风险资产 和无风险资产 构成的组合所复制,即对任意一个 ,我们可以把它表示 为一个自融资组合: 可以看到该组合的收益率部分由组合的时间价值 与风险资产的超额收益 构成.我们考虑该资产的折现价值过程
1.3.4 鞅表示
定理(鞅表示) 设 是 上的布朗运动,而 为 -鞅, 且满足 ,则存在一个 适应的过程 ,使得 .
可以看到,如果 是鞅,那么 可以被表示为一个伊藤积分的形式,即没有 项而仅仅只有 项.再看我们的折现价值过程 ,如果想让它只有 项从而变成一个鞅,我们貌似只需要做变换: ,这样折现价值过程就可以被表示为:
但是,鞅表示定理有个非常非常重要的前提,就是你需要保证 这玩意儿是个伊藤积分,即 需要是一个布朗运动. 我们知道是布朗运动,但是经过这样变换过后的 还是布朗运动么,或者说我们需要如何选择新的测度, 来保证经过变换之后的 仍然是个布朗运动?
1.3.5 Girsanov定理
定理(Grisanov) 设 是 上的布朗运动. 为一个相适应的过程, 定义指数鞅过程,.其中 是初值 的相适应的过程, 表示二次变差.则可以定义新的概率测度 .如果在概率测度 下 是一个布朗运动,那么: 在新的概率测度 下也是一个布朗运动.
这样一来, 我们就找到了新的测度和两个测度之下布朗运动之间的关系.我们看新定义的这个布朗运动:,它的实质是把资产的风险溢价项给消除了.风险溢价是什么?是对承担单位风险的补偿,在新的测度下风险溢价是没有补偿的,所以说在这个世界里,风险是中性的,因此我们把这样定义的新测度 称为风险中性测度,并且用 来表示.
1.3.6 风险定价公式
现在我们知道了变换公式 ,那么在风险中性测度 下,风险资产 所满足的 SDE 也需要进行相应的变化: 由此可见,在风险中性世界里,风险资产 (例如股票) 的收益率完全等于无风险收益率.
此时任意资产的折现价值过程可以被表示为: .我们知道 在 下是一个鞅,那么由鞅的性贡我们可以知道: .常利率假设下有: .
假设我们需要对一个欧式看涨期权进行定价,我们知道该期权在到期日 的价值为 ,则有: 其中: 与PDE方法一致.
我们来总结一下 Risk-neutral Pricing 的几个步骤:
- 找到资产的折现价值过程;
- 作测度变换令这个折现价值在新的测度下为鞅;
- 用 Girsanov 定理找到新的变换;
- 利用鞅性质得到风险中性定价公式。
二.波动率价差
2.1 各种形式
2.1.1 跨式期权
跨式期权(straddle)由一个看涨期权和一个看跌期权组成,这两个期权具有相同的行权价格和到期日.在跨式期权中,这两个期权要么同时买入(跨式期权多头),要么同时卖出(跨式期权空头).
2.1.2 宽跨式期权
与跨式期权一样,宽跨式期权(straggle)由一个看涨期权和一个看跌期权组成,且两个期权的到期时间相同.但在宽跨式期权中,两个期权的行权价格不同.
为了避免混淆,通常假设宽跨式期权由虚值期权组成.如果当前标的市场价格为 100,而交易者想买入 3 月行权价格为 90/110 的宽跨式期权,这意味着他想买入 1 份 3 月行权价格为 90 的看跌期权和 1 份 3 月行权价格为 110 的看涨期权.
2.1.3 蝶式期权
蝶式期权(butterfly)通常就是一个由相同类型(要么都是看涨,要么都看跌)并具有相同到期时间,且合约间行权价格间距相等的 3 份期货合约组成的三腿价差.蝶式期权多头中,买入外部行权价格的期权合约,卖出内部行权价格的期权合约.构成比例固定不变:都为 1x2x1 .
为何买外卖内算作蝶式的多头? 根据损益图,如果不考虑期权费,买外卖内的损益总不小于零,故必须付出一定金额,所以称为多头.
跨式期权潜在收益或风险都是无限的,而蝶式都是有限的.
2.1.4 鹰式期权
鹰式期权(condor)由 4 份期权组成,2 个内部行权价格和两个外部行权价格.构成比例总是 1x1x1x1 ,尽管两个内部行权价格的差额可以变化,但是 2 个最低行权价格的差额一定要与 2 个最高行权价格的差额相等(why?).与蝶式期权一样,鹰式期权中所有期权的到期时间和类型都相同.买入两个外部行权价格的期权,卖出两个内部行权价格的期权就构成了鹰式期权多头.
上述四个策略对标的市场的变动方向没有偏好,损益图为对称的.
2.1.5 比例价差
在波动率价差中,交易者不能完全不关心标的市场的变动方向.交易者可能认为向一个方向变动的可能性要大于向另一个方向变动的可能性.鉴于这个原因,交易者可能希望构建一个当标的向一个方向而不是另一个方向变动时能最大化收益或最小化损失的价差策略.为了实现这个目标,交易者可以构建一个比例价差(ratio spread)——买入并卖出不同数量的期权,所有期权都是同一类型的,且具有相同的到期时间.和其他波动率头寸一样,比例价差也是典型的 Delta 中性策略.
三.希腊值的含义
序:各种希腊值特性
delta
call 的价值变化:标的相对于行权价的变化

put 的价值变化:标的相对于行权价的变化

delta 随标的的变化

call_delta 随 volatility 的变化

put_delta 随 volatility 的变化

call_delta 随到期时间变化

put_delta 随到期时间变化

call_delta 随着时间推移或者波动率下降的变化

Vanna
Vanna:作为 Delta 对波动率的偏导,或者 Vega 对标的价格的偏导.

theta
theta:期权价格随着标的价格变化,此处取了绝对值(call 与 put 一样,都是负的!跟恒正 GAMMA 比较)

vega

gamma
恒正的 GAMMA:


其中
3.0 随机分析几大基础定理
3.0.1 Radon–Nikodym
3.0.1.1 Radon-Nikodym 定理
测度空间 上定义有两个 -有限测度: 和 .定理表明:如果 (i.e. 关于 绝对连续),则存在一个 -可测函数 s.t. 可测集,
-有限: 是一个测度空间, 是上面一测度. 称为其上一个 -有限测度若 可以写成至多可列个有限测度集合的无交并.
绝对连续:实线上 Borel 子集上的测度 称为关于 Lebesgue 测度 绝对连续若对任何 的可测集 有 .记为 .
等价测度: 和 称为等价测度若 且 .
3.0.1.2 Radon-Nikodym 导数
上述函数 在几乎处处意义下唯一,通常写为 ,通常称为 Radon-Nikodym 导数.
3.0.2 Girsanov 定理
二次变差(quadratic variation):,,计算公式为 .
是 Wiener 概率空间 上的 Wiener 过程.令 是适应于 Wiener 过程生成的自然域流 的可测过程,.
定义 关于 的 Doléans-Dade exponential 如下: 其中 是 的二次变差.若 是严格正的鞅,那么可以定义 上的概率测度 s.t. 有 Radon-Nikodym 导数: 则对每个 , 限制在未扩充的 -域 上和 限制在 是等价的.进一步,若 是 下的局部鞅,那么过程 是 下的在 上的局部鞅.
推论:若 是连续过程, 是 下的布朗运动,那么: 是 下的布朗运动.
3.1 BS公式含义
Moneyness 指的是标的现价相对于行权价格的关系.下面考虑的是远期 F,
进行标准化后为:
下面我们记 ,故
标准的 moneyness 为如下均值: 大小关系为: 每级相差 ,这几项都在单位标准差内,所以把这几项转换成百分数,用标准正态的累计密度函数来评估.对这几个量的解释很精细(subtle),与风险中性测度有关.简单来说,有如下解释:
- 是二项 call option 的未来价值,或者风险中性下期权会在价内行权的可能性,with numéraire cash(风险中性资产)
- 是标准化的货币价值的百分比(概率?)
- 是 Delta,或者风险中性下期权会在价内行权的可能性,with numéraire asset(注意与上面的不同之处,cash 与 asset,债券与标的资产)
These have the same ordering, asis monotonic (since it is a CDF): 因为 是单调的(是一个CDF),他们有大小关系
3.2 计价单位的变换
wiki 概述:
在证券交易的金融市场中,计价单位的变换可以用来对资产定价.例如,若 exp 是 时刻投资在货币市场的 元在 时刻的价格,那么以货币市场定价的所有资产(记作 )在风险测度(记作 )下是鞅: 现在假定 是另一个严格正的交易资产(因此在货币市场的定价下是一个鞅),那么我们能根据 Radon-Nikodym 导数定义一个新的概率测度: 根据贝叶斯定理可以证明 关于新的计价单位 在 下是一个鞅:
知乎总结:
3.2.1 概述
在探讨计价单位变换之前,我们先来粗略的看一下这个公式长什么样子: ,这里 是一个计价单位 (Numéraire).乍一看,这个式子和风险中性定价公式 长得一模一样,只不过是将债券 替换为了另一个东西 ,然后从 测度下的期望变成了在 下的期望.
直观上来理解,就是这个意思,风险中性定价公式其实是以债券为计价单位,从而得出的资产的期望价值,那在某些情况下,我们也可以用其他资产作为计价单位,来对某些资产进行定价,或者是进行计算上的简化,这就是计价单位变换的动机所在.
其实可以看到,计价单位变换的本质,就是从一个 测度转化为另一个 测度,所以这个公式的核心,是找到连接两个测度的 Radon-Nikodym 导数 .
3.2.2 计价单位变换公式的推导
3.2.2.1 RN 导数的存在
这里我们想到的第一个问题是,对于任意一个给出的计价单位 ,存在这样的 RN 导数来定义测度 么?
这里我们需要注意的是,计价单位变换公式中,要求 是任意一个价格严格为正的资产,由先前的知识可以知道,这样的资产以债券计价时 (或者说以货币账户计价时) 是一个鞅,即 在测度 下是一个鞅,这样良好的性质,保证了 RN 导数 的存在性. >定理( 的存在性). 设测度 与 为 上的等价摡率测度,则存在 ,满足 ,且 .
根据这个定理,我们可以找到那个存在的 .可以验证,这样定义的 严格为正,且有 ,满足上述定理: 定义 RN 导数过程 ,可以证明 在原本的测度 下是一个 -鞅: (鞅性质).
3.2.2.2 RN 导数的性质
这里设 是由上一节中的定义 给出.
则在两个测度下期望的运算之间,可以用 RN 导数过程来联系.
设 为 -可测的随机变量.给定 ,则无条件期望之间的关系: 给出简单的证明: 更进一步的,给定 ,则条件期望之间的关系: 这里的其实是贝叶斯定理 (Abstract Bayes' Theorem) 的结论.
定理 (Abstract Bayes). 设 为 上的随机变量, 是 上由 导数 定义的测度.设 为 -代数且 ,则有: .
3.2.2.3 计价单位变换公式
首先我们有风险中性定价公式 ,接着根据定义的 RN 导数 ,可以得到:
对于 conditional on 的公式,根据 Abstract Bayes' Theorem 有:
3.2.2.4 新测度下的股价
根据这个计价单位变换公式,在实际运用时我们首先需要找到合适的计价单位 ,然后将风险中性定价公式中的 替换为 ,接着求一个在测度 下的期望 就可以了.
但要求出 ,我们还需要知道某个随机变量在 下的分布,或者说需要知道它新的 dynamics 是什么,此时还需要 Girsanov 定理来帮助我们找到 下的布朗运动 和 下的布朗运动之间的关系 .
定理 (Grisanov). 设 是 上的布朗运动, 为一个相适应的过程,定义指数鞅过程:,其中 是初值 的相适应的过程, 表示二次变差.则可以定义新的概率测度 .如果在概率测度 下 是一个布朗运动,那么: 在新的概率测度 下也是一个布朗运动.
我们这里以股价 为计价单位来运用 Girsanov 定理,找到 与 之间的关系.根据 的定义,有: 根据 Girsanov 定理,可以得到 ,于是在 下,对数价格 的 SDE 为:
3.2.3 一些栗子
根据风险中性公式, 0 时刻欧式看涨期权的价值应为: 此时,如果我们不想计算一个比较复杂的期望,则可以用 作为计价单位处理第一项,得到: 可以看到,我们其实是将原式转化为求事件 分别在 和 下的概率.
由上一节可以知道,在 下有 ,则:
此时可以很快的得到欧式看矤期权的定价公式:
上述推导也说明了, 表示在 下事件 的概率,即代表了在风险中性世界 中,该看涨期权在到期日被执行的概率,而 表示在 下事件 的概率,即 在以股价为计价单位的世界中,该看涨期权在到期日被执行的概率.
期权模型
随机波动率摘要
一. BS公式(Delta对冲下
BS部分参考知乎专栏:Black-Scholes 模型学习框架
假设股价满足 自融资资产组合(此处为期权价格的一个复制)价值变化为 对 ( 时刻衍生品的价值)做Ito公式,比较项的系数得到Delta 对冲下 的 BS 偏微分方程 (在比较系数过程中会使用到替换为,即为持有的债券份额为自融资总份额减去持有的标的份额,标的的持有量已经使用为) 终值条件(看涨期权)为 解即为 BS 公式 注意到 的漂移项 对期权定价没有影响.
BS公式解法(欧式call)
有考虑边界条件
注意到这是一个 Cauchy-Euler 方程,能通过下述变量代换将其转化为一个扩散方程 The solution of the PDE gives the value of the option at any earlier time, Black-Scholes PDE 变为一个扩散方程: 终值条件 现在变为了初值条件 其中 是 Heaviside 阶梯函数,
使用解给定了初值函数 的扩散方程的标准卷积法,得到 经过处理,得到: 其中 N 为正态分布累计密度函数,
BS model给出了期权价格的函数,作为一个波动率的函数.可以通过这个公式在给定期权价格时计算隐含波动率(implied volatility).但事实是 BS 波动率强烈依赖于欧式期权的到期日和行权价.
波动率微笑是指给定到期日下,隐含波动率与行权价(maturity)的关系.
二. Delta对冲
我们现在考虑用衍生品 和其标的资产 构建一个“无风险组合”,考虑这样的自融资组合 ,即每一单位的空头衍生品,我们用 单位多头的股票对其进行对冲 (Hedging),由于其自融资的特性,根据定义,我们有 ,将股价的 SDE 和上一节中通过伊藤-德布林公式求出的 带入这个式子,我们可以得到:
因为要使资产组合为无风险的,
>一个自融资组合 如果是无风险的,则可以表示为 ,且 .
1式即 Delta 对冲法则,将 带入2式我们再次得到 BlackScholes 偏微分方程: .
三. BS公式(风险中性定价下)
3.1 鞅
定义 在 上的随机过程 , ,称其是关于域流 的鞅,如果满足:
- 是 -适应的 (adapted);
- ;
- 对于 ,有 .
3.2 Radon-Nikodym导数
定义 设 和 为 上的等价测度,若 ,a.s.,且有 , 则称 是 关于 的 Radon-Nikodym 导数,记作: .
,即 ,其中 表示在测度 下的期望.进一步的,可以用条件期望定义R-N导数过程: .用鞅和RN导数过程的定义,可以简单的证明,R-N导数过程 是一个 -鞅.
3.3 资产的现值
用 表示风险资产的价值过程.首先要知道,在 模型的假设下,市场是完备 (Complete) 的,即任意资产 都可以被风险资产 和无风险资产 构成的组合所复制,即对任意一个 ,我们可以把它表示 为一个自融资组合: 可以看到该组合的收益率部分由组合的时间价值 与风险资产的超额收益 构成.我们考虑该资产的折现价值过程
3.4 鞅表示
定理(鞅表示) 设 是 上的布朗运动,而 为 -鞅, 且满足 ,则存在一个 适应的过程 ,使得 .
可以看到,如果 是鞅,那么 可以被表示为一个伊藤积分的形式,即没有 项而仅仅只有 项.再看我们的折现价值过程 ,如果想让它只有 项从而变成一个鞅,我们貌似只需要做变换: ,这样折现价值过程就可以被表示为: 但是,鞅表示定理有个非常非常重要的前提,就是你需要保证 这玩意儿是个伊藤积分,即 需要是一个布朗运动. 我们知道是布朗运动,但是经过这样变换过后的 还是布朗运动么,或者说我们需要如何选择新的测度, 来保证经过变换之后的 仍然是个布朗运动?
3.5 Girsanov定理
定理(Grisanov) 设 是 上的布朗运动 为一个相适应的过程, 定义指数鞅过程, 其中 是初值 的相适应的过程, 表示二次变差.则可以定义新的概率测度 .如果在概率测度 下 是一个布朗运动,那么: 在新的概率测度 下也是一个布朗运动.
这样一来, 我们就找到了新的测度和两个测度之下布朗运动之间的关系.我们看新定义的这个布朗运动:,它的实质是把资产的风险溢价项给消除了.风险溢价是什么?是对承担单位风险的补偿,在新的测度下风险溢价是没有补偿的,所以说在这个世界里,风险是中性的,因此我们把这样定义的新测度 称为风险中性测度,并且用 来表示.
3.6 风险定价公式
现在我们知道了变换公式 ,那么在风险中性测度 下,风险资产 所满足的 SDE 也需要进行相应的变化: 由此可见,在风险中性世界里,风险资产 (例如股票) 的收益率完全等于无风险收益率.
此时任意资产的折现价值过程可以被表示为: .我们知道 在 下是一个鞅,那么由鞅的性贡我们可以知道: . 常利率假设下有: .
假设我们需要对一个欧式看涨期权进行定价,我们知道该期权在到期日 的价值为 ,则有: 其中: 与PDE方法一致.
我们来总结一下 Risk-neutral Pricing 的几个步骤:
- 找到资产的折现价值过程;
- 作测度变换令这个折现价值在新的测度下为鞅;
- 用 Girsanov 定理找到新的变换;
- 利用鞅性质得到风险中性定价公式。
四. SDE WITH ANN
定价模型很重要的一点是能快速地根据现有或者历史价格校准模型.
四. 局部波动率
4.1 Dupire 的工作1994
局部波动率基于如下: 其中, 是 和 的确定性函数, 是固定的参数.在风险中性测度时, 下. 一旦 给定,那么模型也就定了.
Dupire(1994)证明了当给定了关于 (行权价) 和 (到期日) 的期权价格函数 时,局部波动率 是唯一确定的.
令 是如下定义的 的转移密度函数(transitional density function): >转移密度函数: at at ,指在 时刻 条件下在 时刻时 的分布 其中 代表风险中性测度.众所周知 满足倒向的 Fokker-Planck 方程: 其中 一维情形,Fokker-Planck 方程有两个参数,一是拓扑参数 ,另一是扩散
又可证它也满足前向的 Fokker-Planck 方程 其中 且 下面可推导 Dupire 方程,看涨期权的价格满足 其中 是 的风险中性测度.对(4.1)关于 做一次和二次微分,有
已知 满足前向 Fokker-Planck 方程 (4.1)对 微分,有 这里我们假设 与 无关,故我们得到了 Dupire 方程: Dupire 方程最大的优点是把局部波动率函数用期权价格和他们的微分表示出来了 以上结果扩展到了时间依赖的利率,我们只要将 替换为 即可.
局限性
- 可用的观测值是很少的
- 数值微分不可靠,二阶的更甚
确定方程的传统做法是二元样条插值,再对模型进行校准(calibration).
局部波动率作为瞬时方差的条件期望
考虑如下形式的一般的随机波动率模型 其中 远期价格 资产价格过程变为 考虑到 , 欧式看涨期权的 -forward 价格为 上式两端对 求微分 >其中 是 Heaviside函数, 是Dirac函数, , .
对最终的支付应用 Ito 公式并令 有 两侧取条件期望 ,有 上式用到了 鞅的性质.接下来 第二个等式是条件期望公式的一个推广: 或者 因为 故有 进行比较,可知对应的局部波动率模型为 也就是说,局部方差是以最终股票价格 等于行权价 为条件的瞬时方差的风险中性期望.
五. 随机局部波动率模型
5.1 Jex的随机局部波动率模型1999
其中 和 应该是无风险利率减股息收益, 是随机波动率部分, 是波动率的均值回复的平衡点. >Heston随机波动率模型:,.或者()
注意到如果没有 这一项,模型即为Heston模型,而当 为 时模型即为Dupire.
5.2 GAN基于LOCAL_STOCHASTIC_VOLATILITY
This means parameterizing the model pool in a way which is accessible for machine learning techniques and interpreting the inverse problem as a training task of a generative network, whose quality is assessed by anadversary.We pursue this approach in the presentarticle and use as generative models so-called neural stochastic differential equations (SDE),which just means to parameterize the drift and volatility of an Itˆo-SDE by neural networks.
文中指的neural SDE即通过神经网络来对Ito-SDE的漂移项和波动率进行参数化.
这里考虑的某资产的折现后价格过程(discounted price process) :
其中 是某个 中取值的随机过程, 称为杠杆函数(Leverage function)取决于 和资产当前价格.
的选取非常重要,需要很好地校准市场上观测到的隐含波动率.故 需要满足如下条件:
其中 指 Dupire 的local volatility function.注意到(1.1)是 的隐式方程,因为 中需要 .故此时 满足的SDE也成为了一个McKean-Vlasov SDE.
本文采用了 an alternative,fully data-driven 方法,规避了其他计算 Dupire 局部波动率的方法中必须的对波动率曲面插值的做法,即此方法只需离散数据.
令 , 为不同期权的到期日.使用神经网络族 将杠杆函数参数化,参数为 ,i.e.
于是有了neural SDE的生成模型组(generative model class),即使用带参数 的神经网络来参数化漂移项 和波动率项 ,i.e.
本文中,没有漂移项,波动率项如下所示:
依次对每个到期日,参数优化采用如下的校准法则: 其中 是期权的数目, 和 是模型与市场分别的价格.
对固定的 , 是非线性非负凸函数满足 且 对 ,衡量模型和市场价的距离. 某种权重,参数 扮演了对抗(adversarial)的部分,注意到 和 都受 控制.
Rough Volatility
Bergomi's model revisited
Variance swap
A variance swap with maturity is a contract which pays out the realized variance of the logarithmic total returns up to less a strike called the variance swap rate , determined in such a way that the contract has zero value today.
The annualized realized variance of a stock price process for the period with business days is usually defined as The constant denotes the number of trading days per year and is usually fixed to 252 so that . We assume the market is arbitrage-free and prices of traded instruments are represented as conditional expectations with respect to an equivalent pricing measure .
A standard result gives that as , we have
when is a continuous semimartingale.
Approximating the realized variance by the quadratic variation of the log returns works very well for variance swaps, but care should be taken in practise if we price short dated non-linear payoffs on realized variance. Denote by , the price at time of a variance swap with maturity . It is given under by
We define the forward variance curve as Note that, if we assume that the S&PX index follows a diffusion process, with a general stochastic volatility process, , the forward variance is given by It can be seen as the forward instantaneous variance for date , observed at . In particular
The current price of a variance swap, , is given in terms of the forward variances as The models used in practice are based on diffusion dynamics where forward variance curves are given as a functional of a finite-dimensional Markov-process: where the function and the m-dimensional Markov-process satisfy some consistency condition, which essentially ensures that for every fixed maturity , the forward variance is a martingale.
※ Pricing under rough volatility ※
ATM volatility skew
其中 是离到期日的时间, 是log-strike.在传统随机波动率模型中, 对短期时间是常数,对长时间与 成反比.经验上观测到 对某些 与 成比例.
forward variance curve
表示 时刻瞬时方差.则 forward variance curve 为:
Wick exponential
对零均值的 Gaussian R.V. ,其 Wick exponential 为
这里只作记号使用,不涉及其运算.
模型推导
Gatheral et al. (2014) 发现已实现方差(realized variance) 与如下模型一致
其中 是 fBm.This relationship was found to hold for all 21 equity indices in the Oxford-Man database, Bund futures, Crude Oil futures, and Gold futures. Perhaps this feature of the time series of volatility is universal?
考虑 fBm 的 Mandelbrot-Van Ness 表示
其中 , 这样选取是为了保证
将 (2)带入(1)并由 ,可以得到 基于 physical measure 的变化:
注意到 是 -可测,而 与 独立且是 Gaussian with mean zero and variance . 用如下记号: 和 有相同分布,仅仅方差变为 .记 则有 且 结合 Wick expenential 这里,由 1 式可知 依赖 的整个历史,所以 是 non-Markovian.而 2 式表示 the conditional distribution of depends on only through the instantaneous variance forecasts
总结,得到如下模型基于实际概率测度 : 其中,两个布朗运动 和 相关系数为 .
Pricing under Q
期权在 t 时刻的定价基于等价鞅测度 on s.t. 资产价格过程 在 下是一个鞅.
在固定的时间域 中,通过 Girsanov 变换, 使得
另一方面 由 而来,而 是一个布朗运动与 以如下关系相关, 其中 是一对独立的标准布朗运动.对第二项的一个标准的测度变换为 其中 ,for ,是一个合适的适应过程,称为波动率风险的市场价格.所以有 将其重写为 由 4 ,在 下, 特别的, 适应于由 生成的域流(和由 生成的域流一致).把上式重写为 指数中的最后一项明显改变了 的边缘分布.虽然 在 下的条件分布是对数正态的,它在 下不是对数正态.
rBergomi model
考虑最简单的测度变换, assuming for simplicity, resp. as a first approximation, 是关于 的确定性函数.则由 6 我们有 其中 .进一步有 forward variance curve
是如下两项的乘积: 依赖于驱动布朗运动的历史;另一项依赖于风险价格 .
模型 7 是 non-Markovian 因为 .
※on deep calibration of rough sv model※
一.介绍
从隐含波动率按 moneyness 和 maturity 的变化可以观察到存在着著名的 smiles 和 at-the-money(ATM) skews 现象,与 BS 公式相悖.特别的,Bayer, Friz, and Gatheral 经验性地表明 ATM skew 符合如下形式:
其中 为 moneynessand , 为 time to maturity .
根据 Gatheral ,扩散的随机波动率模型不能复现当 time to maturity 趋于零时 volatility skew 的幂指数爆炸现象,反而表现为常数现象.
RSV 可定义为一族连续路径的随机波动率模型,其瞬时波动率由一个 Holder 正则性比布兰运动小的随机过程驱动,通常刻画为 Hurst 系数 H<1/2 的分形布朗运动.
这种范式转变的证据现在是 overwhelming ,一方面在物理测度下,时间序列分析表明对数已实现波动率的 Holder 正则为 0.1 阶;另一方面,在定价测度下经验性观察也表明在零附近由模型能够生成 power-law behaviour 的 volatility skew.
模型的一大难点来自于分形布朗运动的非马尔科夫性.
本文介绍两种方法
- one-step approach : 直接学习从隐含波动率曲面到模型参数的映射,
- two-step approach : 第一步学习从模型参数到期权价格的映射,然后根据实际市场价格校准模型.又分为 point-wise approach 和 grid-wise approach,前者将行权价和到期日作为输入,后者事先设定好这两项.
二.模型校准概述(未使用神经网络)
校准(calibration)意思是调整模型参数以使得模型曲面符合由欧式期权通过BS公式计算出的经验隐含波动率曲面.
假设模型有一个参数集 决定, i.e.,由 .进一步,我们假设期权由参数集 决定.E.g.,对看涨看跌期权我们有 ,分别为到期日和 log-moneyness.有些参数由市场观测得到,如现价、利率等,不在校准过程中.定价映射为 带参数 的模型中带参数 的期权的价格.我们通过 给定了有限子集 以及所有可能的期权参数对应的期权价格.校准是决定模型参数以使模型价格 和市场价格 在给定距离度量下最小,i.e.:
事实上,最常用的 是加权最小二乘: 这里的权重 反映了 对应期权的重要性以及 的可靠性.例如可以选择 bid-ask spread 的倒数.
只要模型参数比 少,此时就是超定(overdetermined)的非线性最小二乘问题,通常采用数值迭代的方法解决,如 Levenberg-Marquardt(LM)算法.
rBergomi :表示为 ,参数 ,例如可以设为 模型基于如下系统 其中 是 Hurst 系数,, 是 Wick exponential, 表示初始forward variance curve, 和 是以 相关的布朗运动.
三.深度校准
3.1 one-step approach
Hernandez A. Model calibration with neural networks[J]. Available at SSRN 2812140, 2016.
直接学习校准过程,即将模型参数视作市场价格(隐含波动率)的函数,i.e. 更具体地,训练神经网络基于标签数据 , 及其对应标签
3.2 two-step approach
首先学习定价映射,将模型参数映射为市场价格(或隐含波动率),然后使用标准校准方法进行校准.我们用 表示 是 的通过神经网络得到的近似.然后第二步我们进行校准
两步方法相较而言最大的好处如下:
- 神经网络只负责期权定价,所以能用人工数据来训练.
- 自然地将误差分为定价误差和模型误差.神经网络表现和模型对市场适应性做出的调整相互独立.
3.2.1 two-step approach: 逐点训练(pointwise)和基于网格(grid-based)训练
In this section, we examine its advantages and present an analysis of the objective function with the goal to enhance learning performance. Within this framework, the pointwise approach has the ability to asses the quality of using Monte Carlo or PDE methods, and indeed it is superior training in terms of robustness.
Pointwise learning
step 1:学习映射 即上述(2)式令 .在标准化期权(vanilla option,)情况下,我们可以直接学习隐含波动率映射 ,而不是期权定价的映射 .用 表示神经网络,最优化问题如下:
Step 2: 解决经典的模型校准步骤:
这里 或者 被替换成 step1 中的近似网络 .
第一步中,关键在于训练数据和网络结构的选择.训练数据在于选择 和 的‘先验’的、有实际意义的分布.
Implicit & grid-based learning
用 记关于到期日和行权价的网格.则
step 1:学习映射 ,输入是 ,输出是 这样的 网格. 取值在 中,其中 strikes maturities 最优化问题变为如下: 其中 . Step 2:
这里期权的参数 是固定了的,不再是学习的一部分.
3.2.2 pointwise versus grid-based
- 最大的不同在于 grid-based 在遇到不在网格上的 T,K 时需要手动插值
- grid-based 方法自然地有 reduction of variance ,
- pointwise 中对使样本符合实际金融数据的操作更简单,改变采样的分布.而 grid-wise 则是通过改变权重或者网格密度.
- grid-based 方法可以看做是一种降低维度的操作,将输入的维度转移到了输出的维度.
四.Pratical implementation
4.1 网络结构与训练
- 隐藏层为 3 层的全连接前馈神经网络,每层 30 个结点
- 输入维度记
- 输出维度为
- 总共有 个参数.
- 激活函数选择 Elu, ,梯度下降选择 Adam.
4.2 校准
使用第二节中讲的 LM 等算法.
五.数值实验
5.1定价近似网络的速度和精确度
※Deep learning volatility: a deep neural network perspective on pricing and calibration in (rough)volatility models※
fBm的 Monte-Carlo 模拟
1.理论基础
Notations: 在单位区间 表示连续函数空间, 表示 -Hölder 连续函数空间, . 和 是 上连续可微和有界连续可微函数空间.
1.1. Hölder spaces and fractional operators
For , the -Hölder space , with the norm is a non-separable Banach space. Following the spirit of Riemann-Liouville fractional operators recalled in Appendix , we introduce the class of Generalised Fractional Operators (GFO). For any we introduce the intervals , and the space , for any
Definition 1.1. For any and , the GFO associated to is defined on as We shall further use the notation , for any . Of particular interest in mathematical finance are the following kernels and operators:
Proposition 1.2. For any and , the operator is continuous.
We develop here an approximation scheme for the following system, generalising the concept of rough volatility in the context of mathematical finance, where the process represents the dynamics of the logarithm of a stock price process: with , and the (strong) solution to the stochastic differential equation
where denotes the state space of the process , usually or The two Brownian motions and , defined on a common filtered probability space , are correlated by the parameter , and the functional is assumed to be smooth on This is enough to ensure that the first stochastic differential equation is well defined. It remains to formulate the precise definition for (Proposition 1.4) to fully specify the system (1.3) and clarify the existence of solutions. Existence and (strong) uniquess of a solution to the second in (1.4) is guaranteed by the following standard assumption :
Assumption 1.3. There exist such that, for all
Proposition 1.4. For any ,the equality holds almost surely for .
Example 1.5. This example is the rough Bergomi model introduced by Bayer, Friz and Gatheral, where with and is the Wick stochastic exponential. This corresponds exactly to with and
1.2 The approximation scheme
We now move on to the core of the project, namely an approximation scheme for the system (1.3). The basic ingredient to construct approximating sequences is a family of iid random variables, which satisfies the following assumption: Assumption 1.6. The family forms an iid sequence of centered random variables with finite moments of all orders and
Following Donsker and Lamperti, we first define, for any , the approximating sequence for the driving Brownian motion as As will be explained later, a similar construction holds to approximate the process : where and Here and satisfy Assumption , with appropriate correlation structure between the pairs that will be made precise later. We shall always use to denote the sequence generating and the one generating . Consequently, we deduce an approximating scheme (up to the interpolating term which decays to zero by Chebyshev's inequality) for as All the approximations above, as well as all the convergence statements below should be understood pathwise, but we omit the dependence in the notations for clarity. The main result here is a convergence statement about the approximating sequence . As usual in weak convergence analysis, convergence is stated in the Skorokhod space of càdlàg processes equipped with the Skorokhod topology. Theorem 1.7. The sequence converges weakly to in . The construction of the proof allows to extend the convergence to the case where is a -dimensional diffusion without additional work. The proof of the theorem requires a certain number of steps: we start with the convergence of the approximation in some Hölder space, which we translate, first into convergence of the stochastic integral in , then, by continuity of the mapping , into convergence of the sequence . All these ingredients are detailed in Section 1.3 below. Once this is achieved, the proof of the theorem itself is relatively straightforward.
1.3. Monte-Carlo.
Theorem 1.7 introduces the theoretical foundations of Monte-Carlo methods (in particular for path-dependent options) for rough volatility models. In this section we give a general and easy-to-understand recipe to implement the class of rough volatility models (1.3). For the numerical recipe to be as general as possible, we shall consider the general time partition on with .
Algorithm 1.8 (Simulation of rough volatility models). (1) Simulate two matrices and with ; (2) simulate M paths of viad and also compute (3) Simulate paths of the fractional driving process using The complexity of this step is in general of order (see Appendix for details). However, this step is easily implemented using discrete convolution with complexity (see Algorithm [B.4 in Appendix for details in the implementation). With the vectors and for , we can write , for , where represents the discrete convolution operator. (4) Use the forward Euler scheme to simulate the log-stock process, for all , as
Remark:
- When , we may skip step (2) and replace by on step (33).
- Step (3) may be replaced by the Hybrid scheme algorithm 11 only when .
Antithetic variates in Algorithm 1.8 are easy to implement as it suffices to consider the uncorrelated random vectors and , for Then and , for , constitute the antithetic variates, which significantly improves the performance of the Algorithm 1.8 by reducing memory requirements, reducing variance and accelerating execution by exploiting symmetry of the antithetic random variables.
1.3.1 Enhancing performance. A standard practice in Monte-Carlo simulation is to match moments of the approximating sequence with the target process. In particular, when the process is Gaussian, matching first and second moments suffices. We only illustrate this approximation for Brownian motion: the left-point approximation may be modified to match moments as where is chosen optimally. Since the kernel is deterministic, there is no confusion with the Stratonovich stochastic integral, and the resulting approximation will always converge to the Itô integral. The first two moments of read The first moment of the approximating sequence 1.8 is always zero, and the second moment reads Equating the theoretical and approximating quantities we obtain for , so that the optimal evaluation point can be computed as In the Riemann-Liouville fractional Brownian motion case, , and the optimal point can be computed in closed form as
1.3.2 Reducing Variance.
As Bayer, Friz and Gatheral, a major drawback in simulating rough volatility models is the very high variance of the estimators, so that a large number of simulations are needed to produce a decent price estimate. Nevertheless, the rDonsker scheme admits a very simple conditional expectation technique which reduces both memory requirements and variance while also admitting antithetic variates. This approach is best suited for calibrating European type options. We consider and the natural filtrations generated by the Brownian motions and In particular the conditional variance process is deterministic. As discussed by Romano and Touzi, and recently adapted to the rBergomi case by McCrickerd and Pakkanen, we can decompose the stock price process as and notice that Thus becomes log-normal and the Black-Scholes closed-form formulae are valid here (European, Barrier options, maximum,...). The advantage of this approach is that the orthogonal Brownian motion is completely unnecessary for the simulation, hence the generation of random numbers is reduced to a half, yielding proportional memory saving. Not only this, but also this simple trick reduces the variance of the Monte-Carlo estimate, hence fewer simulations are needed to obtain the same precision. We present a simple algorithm to implement the rDonsker with conditional expectation and assuming that .
Algorithm 1.9 (Simulation of rough volatility models with Brownian drivers). Consider the equidistant grid . (1) Draw a random matrix with unit variance, and create antithetic variates ; (2) Create a correlated matrix as above; (3) Simulate paths of the fractional driving process using discrete convolution: and store in memory for each (4) use the forward Euler scheme to simulate the log-stock process, for each , as (5) Finally, we have for we may compute any option using the Black-Scholes formula. For instance a Call option with strike would be given by for , where and Thus, the output of the model would be
The algorithm is easily adapted to the case of general diffusions as drivers of the volatility (see Algorithm 1.8 step 2). Algorithm 1.8 is obviously faster than 1.9, especially when using control variates. Nevertheless, with the same number of paths, Algorithm 1.9 remarkably reduces the Monte-Carlo variance, meaning in turn that fewer simulations are needed, making it very competitive for calibration.
2.传统cholesky分解法模拟
If you need to generate correlated Gaussian distributed random variables where is the vector you want to simulate, the vector of means and the given covariance matrix, 1.you first need to simulate a vector of uncorrelated Gaussian random variables, 2.then find a square root of , i.e. a matrix such that . Your target vector is given by A popular choice to calculate is the Cholesky decomposition.
而对于本 rBergomi 模型,
where is a Volterra processt with the scaling property . So far behaves just like . However, the dependence structure is different. Specifically, for where, for and with , where denotes the confluent hypergeometric function. Remark The dependence structure of the Volterra process is markedly different from that of with the MolchanGolosov kernel given by for some constant In particular, for small , correlations drop precipitously as the ratio moves away from 1 .
We also need covariances of the Brownian motion with the Volterra process . With , these are given by and where for future convenience, we have defined the constant, These two formulae may be conveniently combined as Lastly, of course, for . With the number of time steps and the number of simulations, our rBergomi model simulation algorithm may then be summarized as follows.
- Construct the joint covariance matrix for the Volterra process and the Brownian motion and compute its Cholesky decomposition.
- For each time, generate iid normal random vectors and multiply them by the lower triangular matrix obtained by the Cholesky decomposition to get a matrix of paths of and with the correct joint marginals.
- With these paths held in memory, we may evaluate the expectation under of any payoff of interest.
we simulate the process
import numpy as np
import matplotlib.pyplot as plt
import scipy.special as special
def fBm_path_chol(grid_points, M, H, T):
"""
@grid_points: # points in the simulation grid
@H: Hurst Index
@T: time horizon
@M: # paths to simulate
"""
assert 0<H<1.0
## Step1: create partition
X=np.linspace(0, 1, num=grid_points)
# get rid of starting point
X=X[1:grid_points]
## Step 2: compute covariance matrix
Sigma=np.zeros((grid_points-1,grid_points-1))
for j in range(grid_points-1):
for i in range(grid_points-1):
if i==j:
Sigma[i,j]=np.power(X[i],2*H)/2/H
else:
s=np.minimum(X[i],X[j])
t=np.maximum(X[i],X[j])
Sigma[i,j]=np.power(t-s,H-0.5)/(H+0.5)*np.power(s,0.5+H)*special.hyp2f1(0.5-H, 0.5+H, 1.5+H, -s/(t-s))
## Step 3: compute Cholesky decomposition
P=np.linalg.cholesky(Sigma)
## Step 4: draw Gaussian rv
Z=np.random.normal(loc=0.0, scale=1.0, size=[M,grid_points-1])
## Step 5: get V
W=np.zeros((M,grid_points))
for i in range(M):
W[i,1:grid_points]=np.dot(P,Z[i,:])
#Use self-similarity to extend to [0,T]
return W*np.power(T,H)
3.rDonker方法
def fBm_path_rDonsker(grid_points, M, H, T, kernel="optimal"):
"""
@grid_points: # points in the simulation grid
@H: Hurst Index
@T: time horizon
@M: # paths to simulate
@kernel: kernel evaluation point use "optimal" for momen-match or "naive" for left-point
"""
assert 0<H<1.0
## Step1: create partition
dt=1./(grid_points-1)
X=np.linspace(0, 1, num=grid_points)
# get rid of starting point
X=X[1:grid_points]
## Step 2: Draw random variables
dW = np.power(dt, H) *np.random.normal(loc=0, scale=1, size=[M, grid_points-1])
## Step 3: compute the kernel evaluation points
i=np.arange(grid_points-1) + 1
# By default use optimal moment-matching
if kernel=="optimal":
opt_k=np.power((np.power(i,2*H)-np.power(i-1.,2*H))/2.0/H,0.5)
# Alternatively use left-point evaluation
elif kernel=="naive" :
opt_k=np.power(i,H-0.5)
else:
raise NameError("That was not a valid kernel")
## Step 4: Compute the convolution
Y = np.zeros([M, n])
for i in range(int(M)):
Y[i, 1:n] = np.convolve(opt_k, dW[i, :])[0:n - 1]
#Use self-similarity to extend to [0,T]
return Y*np.power(T,H)
※使用GAN对LSV模型的校准※
This means parameterizing the model pool in a way which is accessible for machine learning techniques and interpreting the inverse problem as a training task of a generative network, whose quality is assessed by anadversary.We pursue this approach in the presentarticle and use as generative models so-called neural stochastic differential equations (SDE),which just means to parameterize the drift and volatility of an Itˆo-SDE by neural networks.
1.介绍
文中指的neural SDE即通过神经网络来对Ito-SDE的漂移项和波动率进行参数化.
这里考虑的某资产的折现后价格过程(discounted price process) :
其中 是某个 中取值的随机过程, 称为杠杆函数(Leverage function)取决于 和资产当前价格.
的选取非常重要,需要很好地校准市场上观测到的隐含波动率.故 需要满足如下条件:
其中 指 Dupire 的local volatility function.注意到(1.1)是 的隐式方程,因为 中需要 .故此时 满足的SDE也成为了一个McKean-Vlasov SDE.
本文采用了 fully data-driven 方法,规避了其他计算 Dupire 局部波动率的方法中必须的对波动率曲面插值的做法,即此方法只需离散数据.
令 , 为不同期权的到期日.使用神经网络族 将杠杆函数参数化,参数为 ,i.e.
于是有了neural SDE的生成模型组(generative model class),即使用带参数 的神经网络来参数化漂移项 和波动率项 ,i.e.
本文中,没有漂移项,波动率项如下所示:
依次对每个到期日,参数优化采用如下的校准法则: 其中 是期权的数目, 和 是模型与市场分别的价格.
对固定的 , 是非线性非负凸函数满足 且 对 ,衡量模型和市场价的距离. 某种权重,参数 扮演了对抗(adversarial)的部分,注意到 和 都受 控制.本文中 采用的是 Cont R, Ben Hamida S. Recovering volatility from option prices by evolutionary optimization[J]. 2004.中的 vega-type.
2.VARIANCE REDUCTION FOR PRICING AND CALIBRATION VIA HEDGING AND DEEP HEDGING
介绍在蒙特卡洛定价和校准中利用对冲投资组合作为控制变量的方差缩减技术.在 LSV 校准中非常重要.
考虑有限时域 ,已折现的市场中有 个交易中的金融产品 ,它是在某个概率空间 上在 中取值的随机变量. 是风险中性测度, 假设是右连续的.特别的,假设 是有右连左极路径的 维平方可积鞅.
令 是 可测的随机变量,表示表示某个欧式期权在到期日 的支付.那么通常的对这个期权价格的 Monte Carlo 估计是: 其中, 是以分布 , i.i.d 的.可以简单改造这个估计,加上关于 的随机积分.考虑一个策略 和某个常数 .用 记关于 的随机积分,考虑如下估计: 其中, 是以分布 i.i.d 的.则对于任意的 和 ,这个估计仍是期权价格的无偏估计,因为随机积分的期望消失了.记 则 的方差为: 在以下取法下达到最小 此时 特别地,在沿路径完美对冲的情形下, a.s.,有 和 ,此时 因此,找到一个好的近似对冲投资组合使得 大是很重要的.
2.1 Black&Scholes Delta Hedge
In many cases, of local stochastic volatility models as of form (1.1) and options depending only on the terminal value of the price process, a Delta hedge of the BlackâĂŞScholes model works well.
令 , 是 BS 模型下 时刻的价格.对冲策略为:
2.2 Hedging Strategies as Neural Networks-Deep Hedging
在对冲产品数很多等情况下时,可以将对冲策略用神经网络参数化.令期权的支付是对冲产品最终价值的函数,i.e.,.在马尔科夫模型中,可以用函数表示对冲策略: 对应这样一个神经网络:. 是网络参数.根据Buehler H, Gonon L, Teichmann J, et al. Deep hedging[J]. Quantitative Finance, 2019, 19(8): 1271-1291. 给定 的最优对冲可以如下计算 是凸的损失函数.
为了解决这个最优问题,采用随机梯度下降,随机目标函数 为: 记最优的参数 和最优对冲策略 .
假定激活函数和凸损失函数是光滑的.下面要证明 的梯度是: i.e.,我们可以把梯度移到随机积分中.为此,我们要使用下述定理.
定理 2.1:,令 是
Theorem 2.1. For ling, let be a solution of a stochastic differential equation as described in Theorem with drivers , functionally Lipschitz operators , and a process , which is here for all simply for some constant vector , i.e. Let be a map, such that the bounded càglàd process converges to , then holds true.
推论 2.2:,令 为对冲产品过程 的离散,使得定理 2.1 中的条件都满足.对应的对冲策略 由神经网络 给出,其中网络的激活函数有界 ,且导数有界.那么
(i) 随机积分在 点关于 导数 满足
(ii) 若当 时, ucp 收敛到 ,则离散积分的方向导数,i.e. 随着离散刻度 收敛到
ucp means uniform convergence on compacts in probability,i.e.,if for all . The notation is sometimes used, and is said to converge ucp to .
3. LSV的校准
考虑定义在某个概率空间 上的(1.1)LSV模型, 是风险中性测度.假定随机过程 固定.所以实际中我们可以先令 来近似校准其他参数并固定他们.
主要目标是确定符合市场数据的杠杆函数 ,根据通用近似定理(universal approximation properties),对其参数化.令 为欧式看涨期权的到期日.将 用如下神经网络近似 其中 , .方便起见,通常省略 .当我们写 时, 表示 时刻前所有的参数 .
训练过程中,我们需要计算 LSV 过程关于 的导数.以下结果可以看做 对应的链式法则.从附录 A 推导而来.
定理 3.1:令 为(3.1)形式,神经网络 有界且 ,导数有界且 Lipschitz 连续.则关于 在 点处的导数满足: 初值为 0.这个可以通过常数变易来解,i.e. 其中 表示随机指数(stochastic exponential).
Remark
(i) 只看存在唯一性的话, 为 (3.1) 形式,那么神经网络 有界以及 Lipschitz 足够了,.
(ii) 公式 (3.3) 可以用来倒向传播.
定理 3.1 保证了导数过程的存在唯一性.这也保证了基于梯度搜索的学习算法的建立.
下面叙述如何具体优化.为了记号方便,省略权重 和损失函数 对应的参数 .对每个到期日 ,我们假定有 个期权,行权价为 .对第 个到期日,校准函数的形式为 回忆 指的是对应到期日 和行权价 的模型期权价格. 是某个非负非线性凸的损失函数满足 对 . 是权重.
我们通过迭代地计算最优化问题(3.5),从 和 出发,计算 ,然后解决对应 的(3.5).为了简便记号,去掉 ,考虑一般的到期日 ,(3.5)变为 模型价格由下式给出 我们有 ,其中 那么校准问题变为寻找最小的 因为 是非线性函数,不是 B.1 中的期望形式,标准的随机梯度下降方法不能直接用.我们通过第二节中讲的对冲控制变量 (hedge control variates) 解决这个问题.
3.1 极小化校准方程
考虑标准的对(3.8) 的 Monte-Carlo 模拟: 对 i.i.d 的样本 .Monte-Carlo 误差以 递减.模拟次数 必须很大 .因为由于 非线性,随机梯度下降不能直接使用,所以看起来要计算整个函数 的梯度来最小化(3.9).但 ,这一做法计算成本太大且不稳定,因为要计算 项的和的导数.
一个方便的做法是应用对冲控制变量来降低方差,可以将 Monte-Carlo 的样本数 降为大约 .
假定我们有 个对冲产品(包含价格过程 ),用 表示,为 下的平方可积鞅,在 下取值.对 ,策略 使得 , 为常数,定义 则校准函数(3.8)和(3.9)可以通过替换 为 来定义,变为最小化 对此,我们应用如下梯度下降的变种:从初始猜测 出发,迭代计算 对某个学习率 ,i.i.d 样本 .其中 是基于梯度待确定的量,样本在每次迭代中可以一样,可以另取.本文中另取.
最简单情形下,可以令
注意到(3.10)中随机积分项的导数计算通常是昂贵的.我们进行下述改造.令 定义 : 然后令 注意到基于倒向传播,这一项计算起来是很简单的.Moreover, leaving the stochastic integral away in the inner derivative is justified by its vanishing expectation. During the forward pass, the stochastic integral terms are included in the computation; however the contribution to the gradient (during the backward pass) is partly neglected, which can e.g. be implemented via the tensorflow stop_gradient function.
关于对冲策略的选择,我们可以按照 2.2 节中的方法将其用神经网络参数化,并通过下式计算最优的权重 : 对 i.i.d 样本 和损失函数 .此处 这意味着迭代两个优化步骤,i.e.,优化(3.11)中的 (固定 ) 和(3.14)中的 (固定 ).
4. 数值实验流程
实际使用的 SABR-LSV 模型如下 参数为 ,初值有 . 和 是两个相关的布朗运动.
Remark:一般使用的是关于 的对数价格 .故模型也可写为: 注意到 是一个几何布朗运动,也就是说它有表达式:
生成样本
在已有文献中,有推荐的局部波动率函数族 如下: 其中 且参数满足如下约束: 令 , 如下定义: 文中作者修改为: 其中 注意到 与 有关.所以在做 Monte Carlo 模拟时,我们将 替换为 , 是 Monte Carlo 模拟的时间间隔. What is left to be specified are the parameters 模型变为: 上式是用来生成人工市场价格样本的.
所以我们实际的做法是随机对 中的 采样再根据 (1) 计算出价格,然后对 SABR-LSV 模型进行校准,i.e. 寻找使模型符合上述价格的参数 , 以及 .
到期日 ,每个到期日 对应行权价为 .用 Monte-Carlo 模拟以 间隔计算价格.
具体如下:
- 在 下对 以给定分布进行模拟.
- 对每个 ,根据(1)式计算 and strikes for and 对应的欧式期权的价格.每个 分别使用不同的 条布朗运动轨道.
- 保存这些价格数据
准备做的工作(弃案)
寻找最适合市场波动率曲面的“复合”模型,即假设市场波动率曲面实际是由一些波动率模型的凸组合决定的.
回忆:波动率曲面即隐含波动率以 :time to maturity 和 :log-moneyness 为自变量构成的曲面. 例如,我们可以假设当前 ,其中 .
大致做法
记号:分别以 、 记 Heston 和 rough 模型的参数集,以 、 记该两者通过神经网络训练得到的从模型参数到市场价格(隐含波动率)的映射,、 为前述凸组合系数.
我们这里考虑直接通过神经网络来学习市场波动率曲面 到凸组合系数 的映射.
我们对两个模型的参数以及 分别均匀采样,然后根据两者的模型分别模拟出不同凸组合下两者的复合波动率曲面,但要注意的是两者采用同一个参数 (即两个标准布朗运动的相关系数)并且一个凸组合下两模型使用同一条 Monte-Carlo 轨道.这时,忽略掉模型的参数,我们有了带有 标签的许多波动率曲面样本,我们利用前述 grid-based 的方法通过神经网络学习从波动率曲面到凸组合系数的映射.
知道了 后,如何校准出两个模型分别的参数?
LSV-ROUGH 模型的校准
模型:
杠杆函数:
主要共有两个神经网络,一个负责 Rough 的部分,一个负责 LSV 的部分.
一方面,Rough 部分的网络对应的即 Bayer 提出的 two-step 校准方法,即如下模型 ((1.1)中 时): 对应的从模型参数到模型对应价格的映射的网络.只需用人工模拟数据训练一次后,网络就固定住了,在校准等步骤中是不会再变动的.
回忆:用 记关于到期日和行权价的网格.则 step 1:学习映射 ,输入是 ,输出是 这样的 网格. 取值在 中,其中 strikes maturities 最优化问题变为如下: 其中 . Step 2:
另一方面,我们将 这个函数用网络近似,这个网络中的参数是随着校准不断变动的.具体地,令 为欧式看涨期权的到期日.将 用如下神经网络近似 其中 , .
为了记号方便,省略权重 和损失函数 对应的参数 .对每个到期日 ,我们假定有 个期权,行权价为 .对第 个到期日,校准函数的形式为 回忆 指的是对应到期日 和行权价 的模型期权价格. 是某个非负非线性凸的损失函数满足 对 . 是权重.
我们通过迭代地计算最优化问题(1.3),从 和 出发,计算 ,然后解决对应 的(1.3).为了简便记号,去掉 ,考虑一般的到期日 ,(1.3)变为 模型价格由下式给出 我们有 ,其中 那么校准问题变为寻找最小的 我们通过第二节中讲的对冲控制变量 (hedge control variates) 解决这个问题.
考虑标准的对(3.8) 的 Monte-Carlo 模拟: 对 i.i.d 的样本 .Monte-Carlo 误差以 递减.模拟次数 必须很大 .因为由于 非线性,随机梯度下降不能直接使用,所以看起来要计算整个函数 的梯度来最小化(3.9).但 ,这一做法计算成本太大且不稳定,因为要计算 项的和的导数.
一个方便的做法是应用对冲控制变量来降低方差,可以将 Monte-Carlo 的样本数 降为大约 .
假定我们有 个对冲产品(包含价格过程 ),用 表示,为 下的平方可积鞅,在 下取值.对 ,策略 使得 , 为常数,定义 则校准函数(3.8)和(3.9)可以通过替换 为 来定义,变为最小化
算法1:模型的校准步骤
-
# 初始化网络参数
-
-
# 定义初始模拟轨道数和初始步骤值
-
-
# 定义时间离散间隔和误差容忍度
-
-
:
-
-
# 计算此次切片的初始正规化权重
-
-
-
-
-
-
-
-
-
-
-
-
-
-
算法2:超参的更新
附录
证明:
首先定理 A.2 暗示了 的解存在唯一性.这里驱动过程是一维的 .事实上,若 有界,对 左极右连,对 Lipschitz 连续以一个与 无关的 Lipschitz 常数. 为 functionally Lipschitz,得到结论.这些条件由 的形式和 的条件保证.
为了证明导数过程的形式,我们对如下系统应用定理 A.3: 和 以及 在定理 A.3 中,. 为 ucp 收敛到 事实上,, 等度连续.因此,点点收敛暗示对 的一致连续.This together with being piecewise constant in yields: whence ucp convergence of the first term in (3.4). The convergence of term two is clear. The one of term three follows again from the fact that the family is equicontinuous, which is again a consequence of the form of the neural networks.
By the assumptions on the derivatives, is functionally Lipschitz. Hence Theorem A.2 yields the existence of a unique solution to (3.2) and Theorem A.3 implies convergence.
Proof. Consider the extended system and where we obtain existence, uniqueness and stability for the second equation by Theorem A.3, and from where we obtain ucp convergence of the integrand of the first equation: since stochastic integration is continuous with respect to the ucp topology we obtain the result.
文献
- 首次在波动率校准中运用神经网络 Hernandez A. Model calibration with neural networks[J]. Available at SSRN 2812140, 2016.
- rough波动率模型的神经网络校准 Bayer C, Horvath B, Muguruza A, et al. On deep calibration of (rough) stochastic volatility models[J]. arXiv preprint arXiv:1908.08806, 2019.和 Horvath B, Muguruza A, Tomas M. Deep learning volatility: a deep neural network perspective on pricing and calibration in (rough) volatility models[J]. Quantitative Finance, 2021, 21(1): 11-27. Github 代码
- LSV模型GAN校准 Cuchiero C, Khosrawi W, Teichmann J. A generative adversarial network approach to calibration of local stochastic volatility models[J]. Risks, 2020, 8(4): 101. Github 代码
- 损失函数中不同期权权重取法 Cont R, Ben Hamida S. Recovering volatility from option prices by evolutionary optimization[J]. 2004.
- fBm的MC模拟 Horvath B, Jacquier A J, Muguruza A. Functional central limit theorems for rough volatility[J]. Available at SSRN 3078743, 2017. Github 代码
- rBergomi提出 Bayer C, Friz P, Gatheral J. Pricing under rough volatility[J]. Quantitative Finance, 2016, 16(6): 887-904.
wing model
一. 模型建立
波动率模型 Wing Model(知乎, maomao.run)

Wing Model是期权交易中常见的一种对波动率进行建模的方法. 它通过调整参数, 将市场中一个系列的期权的隐含波动率拟合到一个曲线上. Wing Model 把隐含波动率曲线分为 6 个区域, 以 ATM Forward(期权对应标的远期价)为中心, 左边区域 1, 2, 3 构成 Put Wing, 右边区域 4, 5, 6 构成 Call Wing. 其中, 区域 1, 6 为常数波动率部分, 区域 3, 4 为抛物线部分, 区域 2, 5 则为过渡部分(其实也是抛物线). x 轴为期权的行权价(或者对数化行权价), y 轴为期权波动率.
| 名称 | 参数 | 描述 |
|---|---|---|
| atm forward | atm | 期权对应合成期货价 |
| volatility reference | vr(vc) | 中心点参考波动率 |
| slope reference | sr(sc) | 中心点参考斜率,也是 Put Wing3 和 Call Wing4 抛物线共同的一次项系数 |
| put curvature | pc | Put Wing3 抛物线的二次项系数 |
| call curvature | cc | Call Wing4 抛物线的二次项系数 |
| down cutoff | dc | Put Wing 2和3 交界点 x 值, dc<0 |
| up cutoff | uc | Call Wing 4和5 交界点 x 值, uc>0 |
| down smoothing range | dsm | Put Wing 用来计算 1 和 2 交界点 x 值的参数 |
| up smoothing range | usm | Call Wing 用来计算 5 和 6 交界点 x 值的参数 |
| skew swimmingness rate | ssr | 斜率游离系数, 取值范围 , 在计算合成期货价格时, 该参数用来调节 atm 和 ref 的比例 |
| volatility change rate | vcr | 波动率变化系数 |
| slope change rate | scr | 斜率变化系数 |
| reference price | ref | ssr<100 时, 需要定义 ref 用来描述 vcr 和 scr 对中心点波动率和中心点斜率的影响 |
其中,atm已知,dc、dsm、uc、usm一般为经验值,vcr、scr、ssr一般使用默认值0、0、100,vc、sc、pc、cc待拟合.
代码中取的几个默认参数是:dc=-0.2, uc=0.2, dsm=0.5, usm=0.5.
- 以50etf为例,当前2022-12-13 13:12近月合成期货为2.682,那么中间两个区域边界分别为;
- 以300etf为例,当前2022-12-13 13:12近月合成期货为4.012,那么中间两个区域边界分别为;
如果 dc=-0.15, uc=0.15 , 50中间区域边界为 [2.31, 3.12], 300中间区域边界为 [3.45, 4.66].
除了上述参数, 还需要一些中间参数以便于表示最终函数. 其中,
合成期货价格: 是中心点波动率: 为中心点斜率: 在常规使用的时候,下列参数一般取默认值: 将默认值带入中间参数公式,可得: 因此我们平常使用,只需要 这9个参数就 够了. 依照参数的定义, 我们可以定义出区域之间的 5 个分隔点的 x 坐标, 从左到右依次为: 把这个五个点对数化,即: 其中 为原 坐标, 为合成期货价格. 对数化后我们重新定义出 5 个分隔点新的 x 坐标, 从左到右依次为:
函数求解
区域 3 和区域 4 的抛物线函数是由参数确定的: 根据各区域连接处导数一致列方程即可求出其他区域的表达式:
二. 根据无套利进行推导
Wing-Model Volatility Skew Manager, 子非鱼根据Jim Gatheral 在Arbitrage-free SVI volatility surfaces 提到的无套利观点和算法对 wing-model 进行公式推导分析,在拟合的基础上进一步根据按定义域分 6 块给出 6 个约束条件判断拟合曲线无蝶式套利.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time : 2020/5/22 11:12 PM
# @Author : 稻草人
# @contact : dybeta2021@163.com
# @File : wing_model.py
# @Desc : orc wing model
from functools import partial
from numpy import ndarray, array, arange, zeros, ones, argmin, minimum, maximum, clip
from numpy.linalg import norm
from numpy.random import normal
from scipy.interpolate import interp1d
from scipy.optimize import minimize
class WingModel(object):
def skew(moneyness: ndarray, vc: float, sc: float, pc: float, cc: float, dc: float, uc: float, dsm: float,
usm: float) -> ndarray:
"""
:param moneyness: converted strike, moneyness
:param vc:
:param sc:
:param pc:
:param cc:
:param dc:
:param uc:
:param dsm:
:param usm:
:return:
"""
assert -1 < dc < 0
assert dsm > 0
assert 1 > uc > 0
assert usm > 0
assert 1e-6 < vc < 10 # 数值优化过程稳定
assert -1e6 < sc < 1e6
assert dc * (1 + dsm) <= dc <= 0 <= uc <= uc * (1 + usm)
# volatility at this converted strike, vol(x) is then calculated as follows:
vol_list = []
for x in moneyness:
# volatility at this converted strike, vol(x) is then calculated as follows:
if x < dc * (1 + dsm):
vol = vc + dc * (2 + dsm) * (sc / 2) + (1 + dsm) * pc * pow(dc, 2)
elif dc * (1 + dsm) < x <= dc:
vol = vc - (1 + 1 / dsm) * pc * pow(dc, 2) - sc * dc / (2 * dsm) + (1 + 1 / dsm) * (
2 * pc * dc + sc) * x - (pc / dsm + sc / (2 * dc * dsm)) * pow(x, 2)
elif dc < x <= 0:
vol = vc + sc * x + pc * pow(x, 2)
elif 0 < x <= uc:
vol = vc + sc * x + cc * pow(x, 2)
elif uc < x <= uc * (1 + usm):
vol = vc - (1 + 1 / usm) * cc * pow(uc, 2) - sc * uc / (2 * usm) + (1 + 1 / usm) * (
2 * cc * uc + sc) * x - (cc / usm + sc / (2 * uc * usm)) * pow(x, 2)
elif uc * (1 + usm) < x:
vol = vc + uc * (2 + usm) * (sc / 2) + (1 + usm) * cc * pow(uc, 2)
else:
raise ValueError("x value error!")
vol_list.append(vol)
return array(vol_list)
def loss_skew(cls, params: [float, float, float], x: ndarray, iv: ndarray, vega: ndarray, vc: float, dc: float,
uc: float, dsm: float, usm: float):
"""
:param params: sc, pc, cc
:param x:
:param iv:
:param vega:
:param vc:
:param dc:
:param uc:
:param dsm:
:param usm:
:return:
"""
sc, pc, cc = params
vega = vega / vega.max()
value = cls.skew(x, vc, sc, pc, cc, dc, uc, dsm, usm)
return norm((value - iv) * vega, ord=2, keepdims=False)
def calibrate_skew(cls, x: ndarray, iv: ndarray, vega: ndarray, dc: float = -0.2, uc: float = 0.2, dsm: float = 0.5,
usm: float = 0.5, is_bound_limit: bool = False,
epsilon: float = 1e-16, inter: str = "cubic"):
"""
:param x: moneyness
:param iv:
:param vega:
:param dc:
:param uc:
:param dsm:
:param usm:
:param is_bound_limit:
:param epsilon:
:param inter: cubic inter
:return:
"""
vc = interp1d(x, iv, kind=inter, fill_value="extrapolate")([0])[0]
# init guess for sc, pc, cc
if is_bound_limit:
bounds = [(-1e3, 1e3), (-1e3, 1e3), (-1e3, 1e3)]
else:
bounds = [(None, None), (None, None), (None, None)]
initial_guess = normal(size=3)
args = (x, iv, vega, vc, dc, uc, dsm, usm)
residual = minimize(cls.loss_skew, initial_guess, args=args, bounds=bounds, tol=epsilon, method="SLSQP")
assert residual.success
return residual.x, residual.fun
def sc(sr: float, scr: float, ssr: float, ref: float, atm: ndarray or float) -> ndarray or float:
return sr - scr * ssr * ((atm - ref) / ref)
def loss_scr(cls, x: float, sr: float, ssr: float, ref: float, atm: ndarray, sc: ndarray) -> float:
return norm(sc - cls.sc(sr, x, ssr, ref, atm), ord=2, keepdims=False)
def fit_scr(cls, sr: float, ssr: float, ref: float, atm: ndarray, sc: ndarray,
epsilon: float = 1e-16) -> [float, float]:
init_value = array([0.01])
residual = minimize(cls.loss_scr, init_value, args=(sr, ssr, ref, atm, sc), tol=epsilon, method="SLSQP")
assert residual.success
return residual.x, residual.fun
def vc(vr: float, vcr: float, ssr: float, ref: float, atm: ndarray or float) -> ndarray or float:
return vr - vcr * ssr * ((atm - ref) / ref)
def loss_vc(cls, x: float, vr: float, ssr: float, ref: float, atm: ndarray, vc: ndarray) -> float:
return norm(vc - cls.vc(vr, x, ssr, ref, atm), ord=2, keepdims=False)
def fit_vcr(cls, vr: float, ssr: float, ref: float, atm: ndarray, vc: ndarray,
epsilon: float = 1e-16) -> [float, float]:
init_value = array([0.01])
residual = minimize(cls.loss_vc, init_value, args=(vr, ssr, ref, atm, vc), tol=epsilon, method="SLSQP")
assert residual.success
return residual.x, residual.fun
def wing(cls, x: ndarray, ref: float, atm: float, vr: float, vcr: float, sr: float, scr: float, ssr: float,
pc: float, cc: float, dc: float, uc: float, dsm: float, usm: float) -> ndarray:
"""
wing model
:param x:
:param ref:
:param atm:
:param vr:
:param vcr:
:param sr:
:param scr:
:param ssr:
:param pc:
:param cc:
:param dc:
:param uc:
:param dsm:
:param usm:
:return:
"""
vc = cls.vc(vr, vcr, ssr, ref, atm)
sc = cls.sc(sr, scr, ssr, ref, atm)
return cls.skew(x, vc, sc, pc, cc, dc, uc, dsm, usm)
class ArbitrageFreeWingModel(WingModel):
def calibrate(cls, x: ndarray, iv: ndarray, vega: ndarray, dc: float = -0.2, uc: float = 0.2, dsm: float = 0.5,
usm: float = 0.5, is_bound_limit: bool = False, epsilon: float = 1e-16, inter: str = "cubic",
level: float = 0, method: str = "SLSQP", epochs: int = None, show_error: bool = False,
use_constraints: bool = False) -> ([float, float, float], float):
"""
:param x:
:param iv:
:param vega:
:param dc:
:param uc:
:param dsm:
:param usm:
:param is_bound_limit:
:param epsilon:
:param inter:
:param level:
:param method:
:param epochs:
:param show_error:
:param use_constraints:
:return:
"""
vega = clip(vega, 1e-6, 1e6)
iv = clip(iv, 1e-6, 10)
# init guess for sc, pc, cc
if is_bound_limit:
bounds = [(-1e3, 1e3), (-1e3, 1e3), (-1e3, 1e3)]
else:
bounds = [(None, None), (None, None), (None, None)]
vc = interp1d(x, iv, kind=inter, fill_value="extrapolate")([0])[0]
constraints = dict(type='ineq', fun=partial(cls.constraints, args=(x, vc, dc, uc, dsm, usm), level=level))
args = (x, iv, vega, vc, dc, uc, dsm, usm)
if epochs is None:
if use_constraints:
residual = minimize(cls.loss_skew, normal(size=3), args=args, bounds=bounds, constraints=constraints,
tol=epsilon, method=method)
else:
residual = minimize(cls.loss_skew, normal(size=3), args=args, bounds=bounds, tol=epsilon, method=method)
if residual.success:
sc, pc, cc = residual.x
arbitrage_free = cls.check_butterfly_arbitrage(sc, pc, cc, dc, dsm, uc, usm, x, vc)
return residual.x, residual.fun, arbitrage_free
else:
epochs = 10
if show_error:
print("calibrate wing-model wrong, use epochs = 10 to find params! params: {}".format(residual.x))
if epochs is not None:
params = zeros([epochs, 3])
loss = ones([epochs, 1])
for i in range(epochs):
if use_constraints:
residual = minimize(cls.loss_skew, normal(size=3), args=args, bounds=bounds,
constraints=constraints,
tol=epsilon, method="SLSQP")
else:
residual = minimize(cls.loss_skew, normal(size=3), args=args, bounds=bounds, tol=epsilon,
method="SLSQP")
if not residual.success and show_error:
print("calibrate wing-model wrong, wrong @ {} /10! params: {}".format(i, residual.x))
params[i] = residual.x
loss[i] = residual.fun
min_idx = argmin(loss)
sc, pc, cc = params[min_idx]
loss = loss[min_idx][0]
arbitrage_free = cls.check_butterfly_arbitrage(sc, pc, cc, dc, dsm, uc, usm, x, vc)
return (sc, pc, cc), loss, arbitrage_free
def constraints(cls, x: [float, float, float], args: [ndarray, float, float, float, float, float],
level: float = 0) -> float:
"""蝶式价差无套利约束
:param x: guess values, sc, pc, cc
:param args:
:param level:
:return:
"""
sc, pc, cc = x
moneyness, vc, dc, uc, dsm, usm = args
if level == 0:
pass
elif level == 1:
moneyness = arange(-1, 1.01, 0.01)
else:
moneyness = arange(-1, 1.001, 0.001)
return cls.check_butterfly_arbitrage(sc, pc, cc, dc, dsm, uc, usm, moneyness, vc)
"""蝶式价差无套利约束条件
"""
def left_parabolic(sc: float, pc: float, x: float, vc: float) -> float:
"""
:param sc:
:param pc:
:param x:
:param vc:
:return:
"""
return pc - 0.25 * (sc + 2 * pc * x) ** 2 * (0.25 + 1 / (vc + sc * x + pc * x * x)) + (
1 - 0.5 * x * (sc + 2 * pc * x) / (vc + sc * x + pc * x * x)) ** 2
def right_parabolic(sc: float, cc: float, x: float, vc: float) -> float:
"""
:param sc:
:param cc:
:param x:
:param vc:
:return:
"""
return cc - 0.25 * (sc + 2 * cc * x) ** 2 * (0.25 + 1 / (vc + sc * x + cc * x * x)) + (
1 - 0.5 * x * (sc + 2 * cc * x) / (vc + sc * x + cc * x * x)) ** 2
def left_smoothing_range(sc: float, pc: float, dc: float, dsm: float, x: float, vc: float) -> float:
a = - pc / dsm - 0.5 * sc / (dc * dsm)
b1 = -0.25 * ((1 + 1 / dsm) * (2 * dc * pc + sc) - 2 * (pc / dsm + 0.5 * sc / (dc * dsm)) * x) ** 2
b2 = -dc ** 2 * (1 + 1 / dsm) * pc - 0.5 * dc * sc / dsm + vc + (1 + 1 / dsm) * (2 * dc * pc + sc) * x - (
pc / dsm + 0.5 * sc / (dc * dsm)) * x ** 2
b2 = (0.25 + 1 / b2)
b = b1 * b2
c1 = x * ((1 + 1 / dsm) * (2 * dc * pc + sc) - 2 * (pc / dsm + 0.5 * sc / (dc * dsm)) * x)
c2 = 2 * (-dc ** 2 * (1 + 1 / dsm) * pc - 0.5 * dc * sc / dsm + vc + (1 + 1 / dsm) * (2 * dc * pc + sc) * x - (
pc / dsm + 0.5 * sc / (dc * dsm)) * x ** 2)
c = (1 - c1 / c2) ** 2
return a + b + c
def right_smoothing_range(sc: float, cc: float, uc: float, usm: float, x: float, vc: float) -> float:
a = - cc / usm - 0.5 * sc / (uc * usm)
b1 = -0.25 * ((1 + 1 / usm) * (2 * uc * cc + sc) - 2 * (cc / usm + 0.5 * sc / (uc * usm)) * x) ** 2
b2 = -uc ** 2 * (1 + 1 / usm) * cc - 0.5 * uc * sc / usm + vc + (1 + 1 / usm) * (2 * uc * cc + sc) * x - (
cc / usm + 0.5 * sc / (uc * usm)) * x ** 2
b2 = (0.25 + 1 / b2)
b = b1 * b2
c1 = x * ((1 + 1 / usm) * (2 * uc * cc + sc) - 2 * (cc / usm + 0.5 * sc / (uc * usm)) * x)
c2 = 2 * (-uc ** 2 * (1 + 1 / usm) * cc - 0.5 * uc * sc / usm + vc + (1 + 1 / usm) * (2 * uc * cc + sc) * x - (
cc / usm + 0.5 * sc / (uc * usm)) * x ** 2)
c = (1 - c1 / c2) ** 2
return a + b + c
def left_constant_level() -> float:
return 1
def right_constant_level() -> float:
return 1
def _check_butterfly_arbitrage(cls, sc: float, pc: float, cc: float, dc: float, dsm: float, uc: float, usm: float,
x: float, vc: float) -> float:
"""检查是否存在蝶式价差套利机会,确保拟合time-slice iv-curve 是无套利(无蝶式价差静态套利)曲线
:param sc:
:param pc:
:param cc:
:param dc:
:param dsm:
:param uc:
:param usm:
:param x:
:param vc:
:return:
"""
# if x < dc * (1 + dsm):
# return cls.left_constant_level()
# elif dc * (1 + dsm) < x <= dc:
# return cls.left_smoothing_range(sc, pc, dc, dsm, x, vc)
# elif dc < x <= 0:
# return cls.left_parabolic(sc, pc, x, vc)
# elif 0 < x <= uc:
# return cls.right_parabolic(sc, cc, x, vc)
# elif uc < x <= uc * (1 + usm):
# return cls.right_smoothing_range(sc, cc, uc, usm, x, vc)
# elif uc * (1 + usm) < x:
# return cls.right_constant_level()
# else:
# raise ValueError("x value error!")
if dc < x <= 0:
return cls.left_parabolic(sc, pc, x, vc)
elif 0 < x <= uc:
return cls.right_parabolic(sc, cc, x, vc)
else:
return 0
def check_butterfly_arbitrage(cls, sc: float, pc: float, cc: float, dc: float, dsm: float, uc: float, usm: float,
moneyness: ndarray, vc: float) -> float:
"""
:param sc:
:param pc:
:param cc:
:param dc:
:param dsm:
:param uc:
:param usm:
:param moneyness:
:param vc:
:return:
"""
con_arr = []
for x in moneyness:
con_arr.append(cls._check_butterfly_arbitrage(sc, pc, cc, dc, dsm, uc, usm, x, vc))
con_arr = array(con_arr)
if (con_arr >= 0).all():
return minimum(con_arr.mean(), 1e-7)
else:
return maximum((con_arr[con_arr < 0]).mean(), -1e-7)
最简单情形的自动对冲
选用近月或次月(可选)离合成期货最近的行权价对应的合成期货对作为对冲合约; 手动填入本次的目标$delta以及可以接受的一个上下范围区间,对冲必须要达到目标$delta一次后才会考虑上下范围; 例子:当前实际$delta为5%,目标$delta为20%,给定的上下容忍范围为10%,那么刚开始对冲的情形下,$delta必须到达一次20%才停止进入等待状态,后续才会判断$delta出了目标$delta20%上下10%去做对冲,也就是低于10%或高于30%.
$vega方面,不同于$delta我们给定的是目标$delta,$vega我们会给一个本次$vega,$vega我们会实时计算从本次对冲开始下的单对应的$vega,该$vega未到设定的本次要做的$vega前我们只用call或者put去做对冲,在该$vega达到本次要做的$vega后我们只会用合成期货去进行对冲;下的单对应的$vega到过一次本次要做的$vega后就再也不考虑$vega这一希腊值了
流程图如下
打散:例如对二分类问题来说,m个样本最多有2^m个可能结果,每种可能结果称为一种**“对分”**,若假设空间能实现数据集D的所有对分,则称数据集能被该假设空间打散。